From 5ef74d676f42ec3ba66b24cc661d5cc854d5283a Mon Sep 17 00:00:00 2001 From: Ken Koch Date: Mon, 16 Feb 2026 11:54:25 -0500 Subject: [PATCH 1/2] fix revokeSession api call format according to the docs, this should be a post to /user_management/sessions/revoke but it was adding the session id to the url instead which caused a 404 and for the session to not be revoked. --- lib/UserManagement.php | 6 ++++-- tests/WorkOS/UserManagementTest.php | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/UserManagement.php b/lib/UserManagement.php index b0bc7a7..d8cdf54 100644 --- a/lib/UserManagement.php +++ b/lib/UserManagement.php @@ -1419,13 +1419,15 @@ public function listSessions(string $userId, array $options = []) */ public function revokeSession(string $sessionId) { - $path = "user_management/sessions/{$sessionId}/revoke"; + $path = "user_management/sessions/revoke"; $response = Client::request( Client::METHOD_POST, $path, null, - null, + [ + "session_id" => $sessionId, + ], true ); diff --git a/tests/WorkOS/UserManagementTest.php b/tests/WorkOS/UserManagementTest.php index 015a60c..260a9a9 100644 --- a/tests/WorkOS/UserManagementTest.php +++ b/tests/WorkOS/UserManagementTest.php @@ -2266,7 +2266,7 @@ public function testListSessions() public function testRevokeSession() { $sessionId = "session_01H7X1M4TZJN5N4HG4XXMA1234"; - $path = "user_management/sessions/{$sessionId}/revoke"; + $path = "user_management/sessions/revoke"; $result = json_encode([ "id" => $sessionId, @@ -2287,7 +2287,7 @@ public function testRevokeSession() Client::METHOD_POST, $path, null, - null, + [ "session_id" => $sessionId ], true, $result ); From 2115ffcba0d3b453344f85d6cb56f9443ba1ac84 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 26 Feb 2026 14:20:32 -0500 Subject: [PATCH 2/2] Pin GitHub Actions --- .claude/CLAUDE.md | 12 + .claude/SDK_DESIGN.md | 1558 +++++++++++++++++ .claude/skills/generate-sdk-models/SKILL.md | 179 ++ .../skills/generate-sdk-resources/SKILL.md | 212 +++ .claude/skills/generate-sdk-tests/SKILL.md | 360 ++++ .claude/skills/generate-sdk/SKILL.md | 161 ++ .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 6 +- .github/workflows/version-bump.yml | 6 +- 9 files changed, 2490 insertions(+), 8 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/SDK_DESIGN.md create mode 100644 .claude/skills/generate-sdk-models/SKILL.md create mode 100644 .claude/skills/generate-sdk-resources/SKILL.md create mode 100644 .claude/skills/generate-sdk-tests/SKILL.md create mode 100644 .claude/skills/generate-sdk/SKILL.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..830c91b --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,12 @@ +# WorkOS PHP SDK + +## SDK Generation + +This SDK uses code generation from OpenAPI specifications. When generating or modifying SDK code, follow the design patterns in `.claude/SDK_DESIGN.md`. + +## Available Skills + +- `/generate-sdk ` - Generate complete SDK from OpenAPI +- `/generate-sdk-models ` - Generate models only +- `/generate-sdk-resources ` - Generate resources only +- `/generate-sdk-tests ` - Generate tests only diff --git a/.claude/SDK_DESIGN.md b/.claude/SDK_DESIGN.md new file mode 100644 index 0000000..1c2f34c --- /dev/null +++ b/.claude/SDK_DESIGN.md @@ -0,0 +1,1558 @@ +# WorkOS PHP SDK Design Philosophy + +This document defines the architectural decisions, patterns, and conventions for the WorkOS PHP SDK. All code generation skills reference this document for consistency. + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Naming Conventions](#naming-conventions) +3. [Type System](#type-system) +4. [Resource Model Pattern](#resource-model-pattern) +5. [Service Pattern](#service-pattern) +6. [Error Handling](#error-handling) +7. [Retry Logic](#retry-logic) +8. [Global Configuration](#global-configuration) +9. [Documentation Standards](#documentation-standards) +10. [Webhook Verification](#webhook-verification) +11. [Custom HTTP Clients](#custom-http-clients) +12. [Middleware/Hooks System](#middlewarehooks-system) +13. [Request/Response Objects](#requestresponse-objects) +14. [Thread Safety](#thread-safety) +15. [Directory Structure](#directory-structure) +16. [Testing Patterns](#testing-patterns) + +--- + +## Architecture Overview + +The SDK follows a service-based architecture similar to Stripe PHP: + +``` +WorkOS\Client +├── ->organizations → WorkOS\Service\OrganizationService +├── ->users → WorkOS\Service\UserService +└── ->{resource} → WorkOS\Service\{Resource}Service + +WorkOS\Resource +├── Organization → Response model +├── User → Response model +└── {Resource} → BaseResource subclass + +WorkOS (class) +├── ::setApiKey() → Global configuration +├── ::getApiKey() → Global configuration +└── ::getDefaultClient() → Lazy-loaded default client +``` + +### Design Principles + +1. **Explicit over implicit** - Prefer explicit client instantiation for multi-tenant safety +2. **Convenience without sacrifice** - Global config for simple use cases, explicit clients for complex ones +3. **Type-safe with PHPDoc** - Full PHPDoc annotations for IDE support and static analysis +4. **Idiomatic PHP** - camelCase methods, PSR-4 autoloading, PSR-12 coding style +5. **Retry by default** - Automatic retries with exponential backoff (opt-out) + +--- + +## Naming Conventions + +### Class Names + +| OpenAPI Name | PHP Class Name | File Name | +| ----------------------- | ------------------------------ | -------------------------------- | +| `Organization` | `Organization` | `Organization.php` | +| `CreateOrganizationDto` | `OrganizationCreateParams` | `OrganizationCreateParams.php` | +| `UpdateOrganizationDto` | `OrganizationUpdateParams` | `OrganizationUpdateParams.php` | +| `UserProfile` | `UserProfile` | `UserProfile.php` | +| `SSO` | `SSO` | `SSO.php` | +| `APIKey` | `ApiKey` | `ApiKey.php` | + +### Naming Rules + +1. **Response models**: Use the resource name directly (`Organization`, `User`) +2. **Request params**: Use `{Resource}CreateParams` or `{Resource}UpdateParams` +3. **Convert DTO suffix**: `CreateWidgetDto` → `WidgetCreateParams` +4. **File names**: PascalCase matching class name (`UserProfile.php`) +5. **Acronyms**: PascalCase in class names (`ApiKey`), preserve in constants (`API_KEY`) + +### Method Names + +| HTTP Method | Path Pattern | PHP Method | Notes | +| ----------- | ----------------- | ------------ | ------------------------ | +| GET | `/resources` | `all` | Returns Collection | +| GET | `/resources/{id}` | `retrieve` | Not `get` or `find` | +| POST | `/resources` | `create` | Consistent | +| PUT/PATCH | `/resources/{id}` | `update` | Consistent | +| DELETE | `/resources/{id}` | `delete` | Not `destroy` | + +### Field Names + +- Convert OpenAPI `camelCase` to PHP `snake_case` for array keys +- Use camelCase for class properties +- Example: `externalId` → `external_id` in arrays, `$externalId` as property + +--- + +## Type System + +### Type Mapping: OpenAPI → PHP → PHPDoc + +| OpenAPI Type | PHP Type | PHPDoc Type | +| ------------------------------ | --------------- | ------------------------------ | +| `string` | `string` | `string` | +| `string` + `format: date-time` | `\DateTime` | `\DateTime` | +| `string` + `format: date` | `\DateTime` | `\DateTime` | +| `string` + `enum` | `string` | `string` (with const class) | +| `integer` | `int` | `int` | +| `number` | `float` | `float` | +| `boolean` | `bool` | `bool` | +| `array` of primitives | `array` | `string[]` or `int[]` | +| `array` of objects | `array` | `Resource[]` | +| `object` (typed via $ref) | `Resource` | `\WorkOS\Resource\Name` | +| `object` (untyped/freeform) | `array` | `array` | +| `oneOf`/`anyOf` | `mixed` | `TypeA\|TypeB` | +| Optional field | `?type` | `null\|type` | +| Nullable field | `?type` | `null\|type` | + +--- + +## Resource Model Pattern + +Resources are data classes that represent API responses. + +### Base Resource Class + +```php + Raw response data + */ + public array $raw; + + /** + * @var ApiResponse|null Last API response metadata + */ + protected ?ApiResponse $lastResponse = null; + + protected function __construct() + { + } + + /** + * Create instance from API response data. + * + * @param array $data + * @return static + */ + public static function constructFromResponse(array $data): static + { + $instance = new static(); + $instance->raw = $data; + $instance->populateFromResponse($data); + return $instance; + } + + /** + * Populate instance properties from response data. + * + * @param array $data + */ + abstract protected function populateFromResponse(array $data): void; + + /** + * Get the last API response. + */ + public function getLastResponse(): ?ApiResponse + { + return $this->lastResponse; + } + + /** + * Set the last API response. + */ + public function setLastResponse(ApiResponse $response): void + { + $this->lastResponse = $response; + } +} +``` + +### Resource Implementation + +```php +id = $data['id']; + $this->name = $data['name']; + $this->externalId = $data['external_id'] ?? null; + $this->state = $data['state'] ?? 'active'; + $this->createdAt = new \DateTime($data['created_at']); + $this->updatedAt = new \DateTime($data['updated_at']); + + if (isset($data['domains'])) { + $this->domains = array_map( + fn($d) => Domain::constructFromResponse($d), + $data['domains'] + ); + } + } +} +``` + +### Enum Pattern + +```php +client = $client; + } + + public function getClient(): WorkOSClient + { + return $this->client; + } + + /** + * @param array|null $params + * @param array|null $opts + * @return mixed + */ + protected function request(string $method, string $path, ?array $params = null, ?array $opts = null) + { + return $this->client->request($method, $path, $params, $opts); + } + + /** + * @param array|null $params + * @param array|null $opts + * @return Collection + */ + protected function requestCollection(string $method, string $path, ?array $params = null, ?array $opts = null): Collection + { + return $this->client->requestCollection($method, $path, $params, $opts); + } + + /** + * Build a path with URL-encoded IDs. + * + * @param string $basePath Path with %s placeholders + * @param string ...$ids IDs to interpolate + * @return string + */ + protected function buildPath(string $basePath, string ...$ids): string + { + foreach ($ids as $id) { + if ($id === '' || trim($id) === '') { + throw new \WorkOS\Exception\InvalidArgumentException( + 'The resource ID cannot be null or whitespace.' + ); + } + } + return sprintf($basePath, ...array_map('urlencode', $ids)); + } +} +``` + +### Service Implementation + +```php +|null $opts Request options + * @return Collection + * + * @throws \WorkOS\Exception\ApiException + */ + public function all(?array $params = null, ?array $opts = null): Collection + { + return $this->requestCollection('get', '/organizations', $params, $opts); + } + + /** + * Retrieve an organization by ID. + * + * @param string $id Organization ID + * @param array|null $opts Request options + * @return Organization + * + * @throws \WorkOS\Exception\ApiException + */ + public function retrieve(string $id, ?array $opts = null): Organization + { + $response = $this->request( + 'get', + $this->buildPath('/organizations/%s', $id), + null, + $opts + ); + return Organization::constructFromResponse($response); + } + + /** + * Create a new organization. + * + * @param array{ + * name: string, + * domain_data?: array, + * external_id?: string, + * metadata?: array + * } $params Organization parameters + * @param array{idempotency_key?: string}|null $opts Request options + * @return Organization + * + * @throws \WorkOS\Exception\ApiException + */ + public function create(array $params, ?array $opts = null): Organization + { + $response = $this->request('post', '/organizations', $params, $opts); + return Organization::constructFromResponse($response); + } + + /** + * Update an organization. + * + * @param string $id Organization ID + * @param array{ + * name?: string, + * domain_data?: array, + * external_id?: string, + * metadata?: array + * } $params Update parameters + * @param array|null $opts Request options + * @return Organization + * + * @throws \WorkOS\Exception\ApiException + */ + public function update(string $id, array $params, ?array $opts = null): Organization + { + $response = $this->request( + 'put', + $this->buildPath('/organizations/%s', $id), + $params, + $opts + ); + return Organization::constructFromResponse($response); + } + + /** + * Delete an organization. + * + * @param string $id Organization ID + * @param array|null $opts Request options + * @return void + * + * @throws \WorkOS\Exception\ApiException + */ + public function delete(string $id, ?array $opts = null): void + { + $this->request( + 'delete', + $this->buildPath('/organizations/%s', $id), + null, + $opts + ); + } +} +``` + +### Pagination (Collection) + +```php + + */ +class Collection implements \IteratorAggregate, \Countable +{ + /** @var T[] */ + public array $data = []; + + public ?string $after = null; + public ?string $before = null; + + private WorkOSClient $client; + private string $path; + private array $params; + private string $resourceClass; + + /** + * Auto-paginate through all results. + * + * @return \Generator + */ + public function autoPagingIterator(): \Generator + { + $page = $this; + while (true) { + foreach ($page->data as $item) { + yield $item; + } + if ($page->after === null) { + break; + } + $page = $page->nextPage(); + } + } + + public function hasMore(): bool + { + return $this->after !== null; + } + + /** + * @return self + */ + public function nextPage(): self + { + if ($this->after === null) { + throw new Exception\InvalidArgumentException('No more pages available'); + } + $params = array_merge($this->params, ['after' => $this->after]); + return $this->client->requestCollection('get', $this->path, $params, []); + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->data); + } + + public function count(): int + { + return count($this->data); + } +} +``` + +--- + +## Error Handling + +### Error Hierarchy + +```php +httpStatus = $httpStatus; + $this->requestId = $requestId; + $this->errorBody = $errorBody; + } +} + +class AuthenticationException extends ApiException {} // 401 +class AuthorizationException extends ApiException {} // 403 +class NotFoundException extends ApiException {} // 404 +class UnprocessableEntityException extends ApiException {} // 422 + +class RateLimitException extends ApiException // 429 +{ + public ?int $retryAfter; + + public function __construct( + string $message, + ?int $httpStatus = null, + ?string $requestId = null, + ?array $errorBody = null, + ?int $retryAfter = null + ) { + parent::__construct($message, $httpStatus, $requestId, $errorBody); + $this->retryAfter = $retryAfter; + } +} + +class ServerException extends ApiException {} // 5xx +class ConnectionException extends ApiException {} // Network errors +class InvalidArgumentException extends \InvalidArgumentException {} +class ConfigurationException extends \Exception {} +``` + +### HTTP Status Mapping + +| Status | Exception Class | Retryable? | +| ------ | ---------------------------- | ---------- | +| 401 | `AuthenticationException` | No | +| 403 | `AuthorizationException` | No | +| 404 | `NotFoundException` | No | +| 422 | `UnprocessableEntityException` | No | +| 429 | `RateLimitException` | Yes | +| 500 | `ServerException` | Yes | +| 502 | `ServerException` | Yes | +| 503 | `ServerException` | Yes | +| 504 | `ServerException` | Yes | + +--- + +## Retry Logic + +### Configuration + +```php +class WorkOSClient +{ + public const DEFAULT_MAX_RETRIES = 2; + public const RETRY_SLEEP_BASE = 0.5; + public const MAX_RETRY_DELAY = 30; + + public const RETRYABLE_STATUSES = [429, 500, 502, 503, 504]; +} +``` + +### Backoff Strategy + +Exponential backoff with jitter: + +``` +delay = min((2^attempt * 0.5) + random_jitter, 30) +``` + +| Attempt | Base Delay | With Jitter | +| ------- | ---------- | ----------- | +| 1 | 1.0s | 1.0-1.1s | +| 2 | 2.0s | 2.0-2.2s | +| 3 | 4.0s | 4.0-4.4s | +| 4 | 8.0s | 8.0-8.8s | +| 5 | 16.0s | 16.0-17.6s | + +### Retry-After Header + +When API returns `429` with `Retry-After`, respect that value (capped at MAX_RETRY_DELAY). + +### Idempotency Keys + +- Auto-generated UUID for POST requests if not provided +- Same key reused across retry attempts +- Users can provide custom keys for business-level idempotency + +```php +// Auto-generated +$client->organizations->create(['name' => 'Acme']); + +// Manual key +$client->organizations->create( + ['name' => 'Acme'], + ['idempotency_key' => 'user_action_12345'] +); +``` + +--- + +## Global Configuration + +### Pattern 1: Global Configuration (Simple) + +```php +organizations->all(); +``` + +### Pattern 2: Explicit Client (Multi-tenant Safe) + +```php + 'sk_...']); +$client->organizations->all(); + +// Multiple clients for different environments +$prodClient = new WorkOSClient(['api_key' => $_ENV['WORKOS_PROD_KEY']]); +$stagingClient = new WorkOSClient(['api_key' => $_ENV['WORKOS_STAGING_KEY']]); +``` + +### Configuration Options + +| Option | Default | Description | +| -------------------- | -------------------------- | ----------------------------- | +| `api_key` | `$_ENV['WORKOS_API_KEY']` | API key for authentication | +| `max_network_retries`| `2` | Retry attempts (0 to disable) | +| `api_base` | `https://api.workos.com` | API base URL | + +### WorkOS Class + +```php + self::getApiKey(), + 'api_base' => self::$apiBase, + 'max_network_retries' => self::$maxNetworkRetries, + ]); + } + return self::$defaultClient; + } + + public static function resetDefaultClient(): void + { + self::$defaultClient = null; + } +} +``` + +--- + +## Documentation Standards + +### PHPDoc for Classes + +```php +|null $opts Request options + * @return Collection + * + * @throws \WorkOS\Exception\ApiException if the request fails + * + * @see https://workos.com/docs/reference/organization/list + */ +public function all(?array $params = null, ?array $opts = null): Collection +``` + +--- + +## Webhook Verification + +```php + str_starts_with($k, 'v1'), ARRAY_FILTER_USE_KEY); + + if ($timestamp === null || empty($signatures)) { + throw new SignatureVerificationException('Invalid signature header format'); + } + + return [$timestamp, array_values($signatures)]; + } + + private static function verifyTimestamp(int $timestamp, int $tolerance): void + { + if (abs(time() - $timestamp) > $tolerance) { + throw new SignatureVerificationException('Timestamp outside tolerance'); + } + } + + private static function computeSignature(int $timestamp, string $payload, string $secret): string + { + return hash_hmac('sha256', "{$timestamp}.{$payload}", $secret); + } + + private static function verifySignatureMatch(array $signatures, string $expected): void + { + foreach ($signatures as $sig) { + if (hash_equals($sig, $expected)) { + return; + } + } + throw new SignatureVerificationException('Signature mismatch'); + } +} +``` + +--- + +## Custom HTTP Clients + +### HTTP Client Interface + +```php + $headers Request headers + * @param string|null $body Request body + * @param int $timeout Timeout in seconds + * @return HttpResponse + */ + public function request( + string $method, + string $url, + array $headers, + ?string $body, + int $timeout + ): HttpResponse; +} + +class HttpResponse +{ + public function __construct( + public int $status, + public array $headers, + public string $body + ) {} +} +``` + +### Default cURL Client + +```php + $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $this->formatHeaders($headers), + CURLOPT_TIMEOUT => $timeout, + CURLOPT_CUSTOMREQUEST => strtoupper($method), + CURLOPT_HEADER => true, + ]); + + if ($body !== null) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $response = curl_exec($ch); + $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + + if ($response === false) { + $error = curl_error($ch); + curl_close($ch); + throw new \WorkOS\Exception\ConnectionException("cURL error: {$error}"); + } + + curl_close($ch); + + $responseHeaders = $this->parseHeaders(substr($response, 0, $headerSize)); + $responseBody = substr($response, $headerSize); + + return new HttpResponse($status, $responseHeaders, $responseBody); + } + + private function formatHeaders(array $headers): array + { + return array_map( + fn($k, $v) => "{$k}: {$v}", + array_keys($headers), + array_values($headers) + ); + } + + private function parseHeaders(string $headerBlock): array + { + $headers = []; + foreach (explode("\r\n", trim($headerBlock)) as $line) { + if (str_contains($line, ':')) { + [$key, $value] = explode(':', $line, 2); + $headers[strtolower(trim($key))] = trim($value); + } + } + return $headers; + } +} +``` + +### Usage + +```php +// Default (cURL) +$client = new WorkOSClient(['api_key' => 'sk_...']); + +// Custom HTTP client (e.g., Guzzle) +$guzzleClient = new GuzzleHttpClient($guzzle); +$client = new WorkOSClient([ + 'api_key' => 'sk_...', + 'http_client' => $guzzleClient, +]); +``` + +--- + +## Middleware/Hooks System + +### Configuration + +```php + 'sk_...', + 'on_request' => function (RequestInfo $request) { + error_log("WorkOS Request: {$request->method} {$request->path}"); + }, + 'on_response' => function (ResponseInfo $response) { + StatsD::timing('workos.request', $response->duration * 1000); + StatsD::increment("workos.status.{$response->status}"); + }, + 'on_error' => function (ApiException $error, RequestInfo $request) { + Sentry::captureException($error, ['extra' => ['request_id' => $error->requestId]]); + }, +]); +``` + +### Hook Objects + +```php +status = $status; + $this->headers = array_change_key_case($headers, CASE_LOWER); + $this->requestId = $this->headers['x-request-id'] ?? null; + $this->duration = $duration; + $this->retryCount = $retryCount; + } +} +``` + +### Usage + +```php +$org = $client->organizations->retrieve('org_123'); + +// Access response metadata +$org->getLastResponse()->requestId; // "req_abc123" +$org->getLastResponse()->status; // 200 +$org->getLastResponse()->duration; // 0.234 (seconds) +$org->getLastResponse()->retryCount; // 0 +``` + +--- + +## Thread Safety + +PHP doesn't have true multi-threading in typical usage, but the SDK should be safe for: + +1. **Immutable configuration** - Client configuration cannot be changed after construction +2. **No shared mutable state** - Each request uses its own state +3. **Multiple clients** - Different client instances don't interfere + +```php +class WorkOSClient +{ + private readonly string $apiKey; + private readonly string $apiBase; + private readonly int $maxNetworkRetries; + + public function __construct(array $config) + { + $this->apiKey = $config['api_key'] ?? WorkOS::getApiKey() + ?? throw new ConfigurationException('API key is required'); + $this->apiBase = $config['api_base'] ?? WorkOS::getApiBase(); + $this->maxNetworkRetries = $config['max_network_retries'] ?? WorkOS::getMaxNetworkRetries(); + } +} +``` + +--- + +## Directory Structure + +``` +lib/ +├── WorkOS.php # Global configuration +├── WorkOSClient.php # Main client class +├── Collection.php # Pagination wrapper +├── ApiResponse.php # Response metadata +├── Webhook.php # Webhook verification +├── Version.php # SDK version +├── Exception/ # Exception classes +│ ├── ApiException.php +│ ├── AuthenticationException.php +│ ├── NotFoundException.php +│ └── ... +├── HttpClient/ # HTTP client abstraction +│ ├── HttpClientInterface.php +│ ├── CurlClient.php +│ └── HttpResponse.php +├── Resource/ # Response models +│ ├── BaseResource.php +│ ├── Organization.php +│ ├── User.php +│ └── ... +└── Service/ # API services + ├── AbstractService.php + ├── ServiceFactory.php + ├── OrganizationService.php + ├── UserService.php + └── ... + +tests/ +├── TestCase.php +├── fixtures/ +│ └── organizations/ +│ ├── list.json +│ └── retrieve.json +└── Service/ + └── OrganizationServiceTest.php +``` + +--- + +## Testing Patterns + +Tests use PHPUnit with HTTP mocking. + +### Test Base Class + +```php +client = new WorkOSClient([ + 'api_key' => 'sk_test_xxx', + 'max_network_retries' => 0, + ]); + } + + protected function loadFixture(string $path): string + { + return file_get_contents(__DIR__ . '/fixtures/' . $path); + } + + protected function mockResponse(int $status, string $body, array $headers = []): void + { + // Implementation depends on HTTP mocking library + } +} +``` + +### Service Tests + +```php +mockResponse(200, $this->loadFixture('organizations/list.json')); + + $result = $this->client->organizations->all(); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertContainsOnlyInstancesOf(Organization::class, $result->data); + } + + public function testRetrieve(): void + { + $this->mockResponse(200, $this->loadFixture('organizations/retrieve.json')); + + $org = $this->client->organizations->retrieve('org_123'); + + $this->assertInstanceOf(Organization::class, $org); + $this->assertEquals('org_123', $org->id); + } + + public function testCreate(): void + { + $this->mockResponse(201, $this->loadFixture('organizations/create.json')); + + $org = $this->client->organizations->create(['name' => 'Test Org']); + + $this->assertInstanceOf(Organization::class, $org); + } + + public function testUpdate(): void + { + $this->mockResponse(200, $this->loadFixture('organizations/update.json')); + + $org = $this->client->organizations->update('org_123', ['name' => 'Updated']); + + $this->assertInstanceOf(Organization::class, $org); + } + + public function testDelete(): void + { + $this->mockResponse(204, ''); + + $this->client->organizations->delete('org_123'); + + $this->assertTrue(true); // No exception means success + } + + public function testNotFound(): void + { + $this->mockResponse(404, json_encode(['message' => 'Not found'])); + + $this->expectException(\WorkOS\Exception\NotFoundException::class); + + $this->client->organizations->retrieve('invalid'); + } + + public function testAuthenticationError(): void + { + $this->mockResponse(401, json_encode(['message' => 'Unauthorized'])); + + $this->expectException(\WorkOS\Exception\AuthenticationException::class); + + $this->client->organizations->all(); + } +} +``` + +### Error Handling Tests + +```php +mockResponse(429, '', ['Retry-After' => '60']); + + try { + $this->client->organizations->all(); + $this->fail('Expected RateLimitException'); + } catch (RateLimitException $e) { + $this->assertEquals(429, $e->httpStatus); + $this->assertEquals(60, $e->retryAfter); + } + } + + public function testServerError(): void + { + $this->mockResponse(500, json_encode(['message' => 'Internal error'])); + + $this->expectException(ServerException::class); + + $this->client->organizations->all(); + } + + public function testErrorIncludesRequestId(): void + { + $this->mockResponse( + 500, + json_encode(['message' => 'Error']), + ['x-request-id' => 'req_abc123'] + ); + + try { + $this->client->organizations->all(); + } catch (ServerException $e) { + $this->assertEquals('req_abc123', $e->requestId); + } + } +} +``` + +### Retry Logic Tests + +```php + 'sk_test', + 'max_network_retries' => 2, + ]); + + // Mock: first call returns 429, second returns 200 + $this->mockResponseSequence([ + [429, ''], + [200, $this->loadFixture('organizations/list.json')], + ]); + + $result = $client->organizations->all(); + + $this->assertCount(2, $this->getRequestHistory()); + } + + public function testNoRetryOn404(): void + { + $client = new WorkOSClient([ + 'api_key' => 'sk_test', + 'max_network_retries' => 2, + ]); + + $this->mockResponse(404, json_encode(['message' => 'Not found'])); + + try { + $client->organizations->retrieve('invalid'); + } catch (\WorkOS\Exception\NotFoundException $e) { + // Expected + } + + $this->assertCount(1, $this->getRequestHistory()); + } + + public function testIdempotencyKeyReusedOnRetry(): void + { + $client = new WorkOSClient([ + 'api_key' => 'sk_test', + 'max_network_retries' => 1, + ]); + + $this->mockResponseSequence([ + [500, ''], + [201, $this->loadFixture('organizations/create.json')], + ]); + + $client->organizations->create(['name' => 'Test']); + + $requests = $this->getRequestHistory(); + $this->assertEquals( + $requests[0]['headers']['Idempotency-Key'], + $requests[1]['headers']['Idempotency-Key'] + ); + } +} +``` + +### Webhook Verification Tests + +```php +payload}", $this->secret); + $header = "t={$timestamp},v1={$signature}"; + + $result = Webhook::verifySignature($this->payload, $header, $this->secret); + + $this->assertTrue($result); + } + + public function testInvalidSignature(): void + { + $timestamp = time(); + $header = "t={$timestamp},v1=invalid_signature"; + + $this->expectException(SignatureVerificationException::class); + + Webhook::verifySignature($this->payload, $header, $this->secret); + } + + public function testExpiredTimestamp(): void + { + $timestamp = time() - 400; // 6+ minutes ago + $signature = hash_hmac('sha256', "{$timestamp}.{$this->payload}", $this->secret); + $header = "t={$timestamp},v1={$signature}"; + + $this->expectException(SignatureVerificationException::class); + $this->expectExceptionMessage('Timestamp outside tolerance'); + + Webhook::verifySignature($this->payload, $header, $this->secret, 300); + } + + public function testIsSignatureValidReturnsBoolean(): void + { + $timestamp = time(); + $signature = hash_hmac('sha256', "{$timestamp}.{$this->payload}", $this->secret); + $header = "t={$timestamp},v1={$signature}"; + + $this->assertTrue(Webhook::isSignatureValid($this->payload, $header, $this->secret)); + $this->assertFalse(Webhook::isSignatureValid($this->payload, "t={$timestamp},v1=invalid", $this->secret)); + } +} +``` + +### Response Metadata Tests + +```php +mockResponse( + 200, + $this->loadFixture('organizations/retrieve.json'), + ['x-request-id' => 'req_abc123'] + ); + + $org = $this->client->organizations->retrieve('org_123'); + + $this->assertNotNull($org->getLastResponse()); + $this->assertEquals(200, $org->getLastResponse()->status); + $this->assertEquals('req_abc123', $org->getLastResponse()->requestId); + } + + public function testLastResponseIncludesDuration(): void + { + $this->mockResponse(200, $this->loadFixture('organizations/retrieve.json')); + + $org = $this->client->organizations->retrieve('org_123'); + + $this->assertGreaterThan(0, $org->getLastResponse()->duration); + } +} +``` diff --git a/.claude/skills/generate-sdk-models/SKILL.md b/.claude/skills/generate-sdk-models/SKILL.md new file mode 100644 index 0000000..029cdb0 --- /dev/null +++ b/.claude/skills/generate-sdk-models/SKILL.md @@ -0,0 +1,179 @@ +--- +name: generate-sdk-models +description: Generate PHP resource (model) classes from OpenAPI schemas for the WorkOS PHP SDK +arguments: + - name: spec_path + description: Path to the YAML OpenAPI specification file + required: true +--- + +# /generate-sdk-models + +Generate PHP resource (model) classes from OpenAPI schemas for the WorkOS PHP SDK. + +> **Design Reference**: Follow all patterns in [SDK_DESIGN.md](../../SDK_DESIGN.md) + +## Input + +The user will provide a path to a YAML OpenAPI specification file. + +## Instructions + +1. **Read the OpenAPI spec** at the provided path +2. **Extract all schemas** from `#/components/schemas` +3. **For each schema**, generate a PHP resource class in `lib/Resource/` + +## Quick Reference + +### Naming (see SDK_DESIGN.md for full table) + +- Response types: Use resource name directly (`Organization`) +- Input types: Use `{Resource}CreateParams` or `{Resource}UpdateParams` +- Convert DTO: `CreateWidgetDto` → `WidgetCreateParams` + +### Type Mapping (see SDK_DESIGN.md for full table) + +| OpenAPI Type | PHP Type | PHPDoc Type | +| ------------------------- | --------------- | -------------------------- | +| `string` | `string` | `string` | +| `string` + `date-time` | `\DateTime` | `\DateTime` | +| `string` + `enum` | `string` | `string` (use const class) | +| `integer` | `int` | `int` | +| `boolean` | `bool` | `bool` | +| `array` | `array` | `Type[]` | +| `$ref` | `Resource` | `\WorkOS\Resource\Name` | + +### Property Declarations + +```php +// Required property +public string $id; + +// Optional/nullable property +public ?string $externalId = null; + +// DateTime property +public \DateTime $createdAt; + +// Nested object +public ?Organization $organization = null; + +// Array of objects +/** @var Domain[] */ +public array $domains = []; +``` + +## Output Template + +### PHP Resource (`lib/Resource/{Name}.php`) + +```php +id = $data['id']; + $this->name = $data['name']; + $this->externalId = $data['external_id'] ?? null; + $this->state = $data['state'] ?? 'active'; + $this->createdAt = new \DateTime($data['created_at']); + $this->updatedAt = new \DateTime($data['updated_at']); + + if (isset($data['domains'])) { + $this->domains = array_map( + fn($d) => Domain::constructFromResponse($d), + $data['domains'] + ); + } + } +} +``` + +### Enum Constants Class + +For enum types, generate a separate constants class: + +```php + **Design Reference**: Follow all patterns in [SDK_DESIGN.md](../../SDK_DESIGN.md) + +## Input + +The user will provide a path to a YAML OpenAPI specification file. + +## Instructions + +1. **Read the OpenAPI spec** at the provided path +2. **Extract all paths** from the `paths` section +3. **Group operations by resource** (e.g., `/organizations/*` → `OrganizationService`) +4. **For each resource**, generate a PHP service class in `lib/Service/` + +## Quick Reference + +### Method Naming + +| HTTP Method | Path Pattern | PHP Method | +| ----------- | ----------------- | ------------ | +| GET | `/resources` | `all` | +| GET | `/resources/{id}` | `retrieve` | +| POST | `/resources` | `create` | +| PUT/PATCH | `/resources/{id}` | `update` | +| DELETE | `/resources/{id}` | `delete` | + +### Client Request Interface + +| Option | Type | Description | +| ----------------- | ---------------- | ---------------------------------- | +| `method` | `string` | `get`, `post`, `put`, `delete` | +| `path` | `string` | URL path, use buildPath() for IDs | +| `params` | `array\|null` | Query or body parameters | +| `opts` | `array\|null` | Request options (idempotency_key) | + +## Output Template + +### PHP Service (`lib/Service/{Name}Service.php`) + +```php +|null $opts Request options + * @return Collection + * + * @throws \WorkOS\Exception\ApiException if the request fails + */ + public function all(?array $params = null, ?array $opts = null): Collection + { + return $this->requestCollection('get', '/organizations', $params, $opts); + } + + /** + * Retrieve an organization by ID. + * + * @param string $id Organization ID + * @param array|null $opts Request options + * @return Organization + * + * @throws \WorkOS\Exception\ApiException if the request fails + */ + public function retrieve(string $id, ?array $opts = null): Organization + { + $response = $this->request( + 'get', + $this->buildPath('/organizations/%s', $id), + null, + $opts + ); + return Organization::constructFromResponse($response); + } + + /** + * Create a new organization. + * + * @param array{ + * name: string, + * domain_data?: array, + * external_id?: string, + * metadata?: array + * } $params Organization parameters + * @param array{idempotency_key?: string}|null $opts Request options + * @return Organization + * + * @throws \WorkOS\Exception\ApiException if the request fails + */ + public function create(array $params, ?array $opts = null): Organization + { + $response = $this->request('post', '/organizations', $params, $opts); + return Organization::constructFromResponse($response); + } + + /** + * Update an organization. + * + * @param string $id Organization ID + * @param array{ + * name?: string, + * domain_data?: array, + * external_id?: string, + * metadata?: array + * } $params Update parameters + * @param array|null $opts Request options + * @return Organization + * + * @throws \WorkOS\Exception\ApiException if the request fails + */ + public function update(string $id, array $params, ?array $opts = null): Organization + { + $response = $this->request( + 'put', + $this->buildPath('/organizations/%s', $id), + $params, + $opts + ); + return Organization::constructFromResponse($response); + } + + /** + * Delete an organization. + * + * @param string $id Organization ID + * @param array|null $opts Request options + * @return void + * + * @throws \WorkOS\Exception\ApiException if the request fails + */ + public function delete(string $id, ?array $opts = null): void + { + $this->request( + 'delete', + $this->buildPath('/organizations/%s', $id), + null, + $opts + ); + } +} +``` + +### Path Parameter Interpolation + +```php +// Single parameter +$this->buildPath('/organizations/%s', $id) + +// Multiple parameters +$this->buildPath('/organizations/%s/members/%s', $organizationId, $memberId) +``` + +### Nested Resources + +For nested resources like `/organizations/{id}/roles`: + +```php +/** + * List roles for an organization. + * + * @param string $organizationId Organization ID + * @param array|null $params Query parameters + * @param array|null $opts Request options + * @return Collection + */ +public function allRoles(string $organizationId, ?array $params = null, ?array $opts = null): Collection +{ + return $this->requestCollection( + 'get', + $this->buildPath('/organizations/%s/roles', $organizationId), + $params, + $opts + ); +} +``` + +## Output + +For each resource group: + +1. Create `lib/Service/{PascalCaseName}Service.php` +2. Add service property to `WorkOSClient.php` +3. Update `ServiceFactory.php` if using factory pattern diff --git a/.claude/skills/generate-sdk-tests/SKILL.md b/.claude/skills/generate-sdk-tests/SKILL.md new file mode 100644 index 0000000..0e6d3c5 --- /dev/null +++ b/.claude/skills/generate-sdk-tests/SKILL.md @@ -0,0 +1,360 @@ +--- +name: generate-sdk-tests +description: Generate PHPUnit test files for the WorkOS PHP SDK +arguments: + - name: spec_path + description: Path to the YAML OpenAPI specification file + required: true +--- + +# /generate-sdk-tests + +Generate PHPUnit test files for the WorkOS PHP SDK. + +> **Design Reference**: Follow all patterns in [SDK_DESIGN.md](../../SDK_DESIGN.md) + +## Input + +The user will provide a path to a YAML OpenAPI specification file. + +## Instructions + +1. **Read the OpenAPI spec** at the provided path +2. **Identify all resources** and their operations +3. **Extract example data** from schema `example` or `examples` fields +4. **For each resource**, generate: + - A test file in `tests/Service/` + - Fixture files in `tests/fixtures/` derived from OpenAPI examples + +## Quick Reference + +### PHPUnit Assertions + +```php +// Type assertion +$this->assertInstanceOf(Organization::class, $result); + +// Collection assertion +$this->assertInstanceOf(Collection::class, $result); +$this->assertContainsOnlyInstancesOf(Organization::class, $result->data); + +// Property assertion +$this->assertEquals('org_123', $org->id); + +// Exception assertion +$this->expectException(NotFoundException::class); +``` + +### HTTP Mocking + +The test base class should provide HTTP mocking methods: + +```php +// Single response +$this->mockResponse(200, $this->loadFixture('organizations/list.json')); + +// Response with headers +$this->mockResponse(429, '', ['Retry-After' => '60']); + +// Sequence of responses (for retry testing) +$this->mockResponseSequence([ + [429, ''], + [200, $this->loadFixture('organizations/list.json')], +]); +``` + +## Test Categories + +Each test file should include: + +1. **CRUD operation tests** - all, retrieve, create, update, delete +2. **Error tests** - 401, 404, 422, 429, 500 +3. **Retry tests** - retry on 429/5xx, no retry on 4xx +4. **Idempotency tests** - key sent in header, auto-generated, reused on retry + +## Output Template + +### Test File (`tests/Service/{Name}ServiceTest.php`) + +```php +mockResponse(200, $this->loadFixture('organizations/list.json')); + + $result = $this->client->organizations->all(); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertIsArray($result->data); + } + + public function testAllWithParams(): void + { + $this->mockResponse(200, $this->loadFixture('organizations/list.json')); + + $result = $this->client->organizations->all([ + 'limit' => 10, + 'after' => 'cursor_abc', + ]); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertRequestWasMade('GET', '/organizations', [ + 'limit' => '10', + 'after' => 'cursor_abc', + ]); + } + + public function testRetrieve(): void + { + $this->mockResponse(200, $this->loadFixture('organizations/retrieve.json')); + + $org = $this->client->organizations->retrieve('org_123'); + + $this->assertInstanceOf(Organization::class, $org); + $this->assertEquals('org_123', $org->id); + } + + public function testCreate(): void + { + $this->mockResponse(201, $this->loadFixture('organizations/create.json')); + + $org = $this->client->organizations->create(['name' => 'Test Org']); + + $this->assertInstanceOf(Organization::class, $org); + } + + public function testUpdate(): void + { + $this->mockResponse(200, $this->loadFixture('organizations/update.json')); + + $org = $this->client->organizations->update('org_123', ['name' => 'Updated']); + + $this->assertInstanceOf(Organization::class, $org); + } + + public function testDelete(): void + { + $this->mockResponse(204, ''); + + $this->client->organizations->delete('org_123'); + + $this->assertTrue(true); // No exception means success + } + + // === Error Tests === + + public function testNotFound(): void + { + $this->mockResponse(404, json_encode(['message' => 'Not found'])); + + $this->expectException(NotFoundException::class); + + $this->client->organizations->retrieve('invalid'); + } + + public function testAuthenticationError(): void + { + $this->mockResponse(401, json_encode(['message' => 'Unauthorized'])); + + $this->expectException(AuthenticationException::class); + + $this->client->organizations->all(); + } + + public function testServerError(): void + { + $this->mockResponse(500, json_encode(['message' => 'Internal error'])); + + $this->expectException(ServerException::class); + + $this->client->organizations->all(); + } + + // === Retry Tests === + + public function testRetryOnRateLimit(): void + { + $this->mockResponseSequence([ + [429, '', ['Retry-After' => '1']], + [200, $this->loadFixture('organizations/list.json')], + ]); + + $client = new WorkOSClient([ + 'api_key' => 'sk_test_xxx', + 'max_network_retries' => 2, + ]); + $result = $client->organizations->all(); + + $this->assertInstanceOf(Collection::class, $result); + $this->assertRequestCount(2); + } + + public function testNoRetryOn404(): void + { + $this->mockResponse(404, json_encode(['message' => 'Not found'])); + + $client = new WorkOSClient([ + 'api_key' => 'sk_test_xxx', + 'max_network_retries' => 2, + ]); + + try { + $client->organizations->retrieve('invalid'); + } catch (NotFoundException $e) { + // Expected + } + + $this->assertRequestCount(1); + } + + // === Idempotency Tests === + + public function testIdempotencyKeySent(): void + { + $this->mockResponse(201, $this->loadFixture('organizations/create.json')); + + $this->client->organizations->create( + ['name' => 'Test'], + ['idempotency_key' => 'my_key'] + ); + + $this->assertRequestHasHeader('Idempotency-Key', 'my_key'); + } + + public function testIdempotencyKeyAutoGenerated(): void + { + $this->mockResponse(201, $this->loadFixture('organizations/create.json')); + + $this->client->organizations->create(['name' => 'Test']); + + $key = $this->getLastRequestHeader('Idempotency-Key'); + $this->assertNotNull($key); + $this->assertMatchesRegularExpression('/^[0-9a-f-]{36}$/i', $key); + } + + public function testIdempotencyKeyReusedOnRetry(): void + { + $this->mockResponseSequence([ + [500, ''], + [201, $this->loadFixture('organizations/create.json')], + ]); + + $client = new WorkOSClient([ + 'api_key' => 'sk_test_xxx', + 'max_network_retries' => 1, + ]); + $client->organizations->create(['name' => 'Test']); + + $requests = $this->getRequestHistory(); + $this->assertEquals( + $requests[0]['headers']['Idempotency-Key'] ?? null, + $requests[1]['headers']['Idempotency-Key'] ?? null + ); + } + + // === Response Metadata Tests === + + public function testLastResponseAvailable(): void + { + $this->mockResponse( + 200, + $this->loadFixture('organizations/retrieve.json'), + ['x-request-id' => 'req_abc123'] + ); + + $org = $this->client->organizations->retrieve('org_123'); + + $this->assertNotNull($org->getLastResponse()); + $this->assertEquals(200, $org->getLastResponse()->status); + $this->assertEquals('req_abc123', $org->getLastResponse()->requestId); + } +} +``` + +### Fixtures from OpenAPI Examples + +Extract fixture data directly from the OpenAPI spec's `example` fields: + +```yaml +# OpenAPI spec input +components: + schemas: + Organization: + type: object + properties: + id: + type: string + example: "org_01FCPEJXEZR4DSBA625YMGQT9N" + name: + type: string + example: "Acme Corp" + state: + type: string + enum: [active, inactive] + example: "active" + created_at: + type: string + format: date-time + example: "2024-01-01T00:00:00Z" +``` + +↓ generates ↓ + +```json +// tests/fixtures/organizations/retrieve.json +{ + "id": "org_01FCPEJXEZR4DSBA625YMGQT9N", + "name": "Acme Corp", + "state": "active", + "created_at": "2024-01-01T00:00:00Z" +} +``` + +For list endpoints, wrap in pagination structure: + +```json +// tests/fixtures/organizations/list.json +{ + "data": [ + { + "id": "org_01FCPEJXEZR4DSBA625YMGQT9N", + "name": "Acme Corp", + "state": "active", + "created_at": "2024-01-01T00:00:00Z" + } + ], + "list_metadata": { + "after": null, + "before": null + } +} +``` + +If a field lacks an `example`, generate a sensible default based on type: +- `string` → `"string"` +- `string` + `format: uuid` → `"00000000-0000-0000-0000-000000000000"` +- `string` + `format: date-time` → `"2024-01-01T00:00:00Z"` +- `integer` → `0` +- `boolean` → `true` +- `array` → `[]` + +## Output + +For each resource: +1. Create `tests/Service/{PascalCaseName}ServiceTest.php` +2. Create fixture files in `tests/fixtures/{snake_case_name}/` derived from OpenAPI examples diff --git a/.claude/skills/generate-sdk/SKILL.md b/.claude/skills/generate-sdk/SKILL.md new file mode 100644 index 0000000..7057725 --- /dev/null +++ b/.claude/skills/generate-sdk/SKILL.md @@ -0,0 +1,161 @@ +--- +name: generate-sdk +description: Generate a complete PHP SDK (models, services, types, tests) from an OpenAPI specification +arguments: + - name: spec_path + description: Path to the YAML OpenAPI specification file + required: true +--- + +# /generate-sdk + +Generate a complete PHP SDK from an OpenAPI specification. + +> **Design Reference**: Follow all patterns in [SDK_DESIGN.md](../../SDK_DESIGN.md) + +## Input + +The user will provide a path to a YAML OpenAPI specification file. + +## CRITICAL: Complete Coverage Requirement + +**Generate files for EVERY schema and EVERY path group. Do not skip any items.** + +## Execution Steps + +### Step 0: Extract Complete Inventory (REQUIRED FIRST) + +Before writing any code: + +1. **Extract ALL schema names** from `#/components/schemas` +2. **Extract ALL path groups** from `paths` (group by first segment) +3. **Display inventory** before proceeding: + +``` +=== INVENTORY === +Schemas (47): ValidateApiKeyDto, BaseCreateApplicationDto, ... +Path groups (18): api_keys, audit_logs, connect, ... + +Will generate 47 resources and 18 services. +``` + +### Step 1: Create Directory Structure + +``` +lib/ +├── Exception/ +├── HttpClient/ +├── Resource/ +└── Service/ +tests/ +├── fixtures/ +└── Service/ +``` + +### Step 2: Generate Resources (Models) + +For EACH schema in inventory: + +- PHP resource class in `lib/Resource/` +- Follow BaseResource pattern from SDK_DESIGN.md + +Use `/generate-sdk-models` patterns. + +### Step 3: Generate Services + +For EACH path group in inventory: + +- PHP service class in `lib/Service/` +- Include `idempotency_key` option for POST methods + +Use `/generate-sdk-resources` patterns. + +### Step 4: Generate Infrastructure + +Generate client infrastructure per SDK_DESIGN.md: + +- `lib/WorkOS.php` - Global configuration class +- `lib/WorkOSClient.php` - Client with retry logic +- `lib/Collection.php` - Pagination wrapper +- `lib/ApiResponse.php` - Response metadata +- `lib/Webhook.php` - Webhook verification +- `lib/Exception/*.php` - Exception class hierarchy + +### Step 5: Generate Tests + +For EACH service: + +- Test file in `tests/Service/` +- Fixtures in `tests/fixtures/` +- Include CRUD, error, retry, and idempotency tests + +Use `/generate-sdk-tests` patterns. + +### Step 6: Update Autoloading + +Update `composer.json` autoload section: + +```json +{ + "autoload": { + "psr-4": { + "WorkOS\\": "lib/" + } + }, + "autoload-dev": { + "psr-4": { + "WorkOS\\Tests\\": "tests/" + } + } +} +``` + +### Step 7: Verification + +``` +=== VERIFICATION === +Inventory: 47 schemas, 18 path groups +Resources: 47 files ✓ +Services: 18 files ✓ +Tests: 18 files ✓ + +All counts match. Generation complete. +``` + +## Output Summary + +``` +SDK Generation Complete! + +Resources (47): +- lib/Resource/Organization.php +- lib/Resource/User.php +... + +Services (18): +- lib/Service/OrganizationService.php +... + +Infrastructure: +- lib/WorkOS.php (global config) +- lib/WorkOSClient.php (client with retry logic) +- lib/Collection.php (pagination) +- lib/Exception/*.php (error classes) + +Tests (18): +- tests/Service/OrganizationServiceTest.php +... + +Next Steps: +1. composer dump-autoload +2. ./vendor/bin/phpcs +3. ./vendor/bin/phpunit +``` + +## Error Handling + +- **Missing fields**: Log warning, generate stub (don't skip) +- **Circular refs**: Handle with lazy loading if needed +- **Invalid spec**: Report error and stop + +Never silently skip schemas or paths. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7658986..6b773bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,14 +19,14 @@ jobs: matrix: php: ["7.3", "7.4", "8.1", "8.2", "8.3"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: shivammathur/setup-php@9e72090525849c5e82e596468b86eb55e9cc5401 # v2.32.0 with: php-version: ${{ matrix.php }} tools: "composer" - name: Cache Composer packages - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: vendor key: ${{ runner.os }}-php-${{ matrix.php }}-${{ hashFiles('**/composer.lock') }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6a8efde..51c473f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,13 +14,13 @@ jobs: steps: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 with: app-id: ${{ vars.SDK_BOT_APP_ID }} private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }} - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: token: ${{ steps.app-token.outputs.token }} fetch-depth: 0 @@ -32,7 +32,7 @@ jobs: echo "version=$VERSION" >> $GITHUB_OUTPUT - name: Create Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 with: token: ${{ steps.app-token.outputs.token }} tag_name: ${{ steps.version.outputs.version }} diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml index 2ba429f..6ff3993 100644 --- a/.github/workflows/version-bump.yml +++ b/.github/workflows/version-bump.yml @@ -18,13 +18,13 @@ jobs: steps: - name: Generate GitHub App token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@d72941d797fd3113feb6b93fd0dec494b13a2547 # v1.12.0 with: app-id: ${{ vars.SDK_BOT_APP_ID }} private-key: ${{ secrets.SDK_BOT_PRIVATE_KEY }} - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ steps.app-token.outputs.token }} @@ -64,7 +64,7 @@ jobs: echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT - name: Create Pull Request - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: token: ${{ steps.app-token.outputs.token }} branch: version-bump-${{ steps.bump.outputs.new_version }}