diff --git a/docs/printnode/api.md b/docs/printnode/api.md index eff1c44..814d1bf 100644 --- a/docs/printnode/api.md +++ b/docs/printnode/api.md @@ -74,6 +74,58 @@ $client->printJobs->create([...], opts: [ > {tip} The `$opts` argument is accepted in most method calls to the API when using the `Printing` facade as well. +### Integrator / Child Accounts + +If your PrintNode account is an Integrator account, you can act on behalf of a child account by passing one of these keys in `$opts` (the client will translate them to the appropriate `X-Child-Account-*` headers): + +- `child_account_by_id` → sets `X-Child-Account-By-Id` +- `child_account_by_email` → sets `X-Child-Account-By-Email` +- `child_account_by_creator_ref` → sets `X-Child-Account-By-CreatorRef` + +Example: list printers for a child account identified by email: + +```php +$client->printers->all(opts: [ + 'child_account_by_email' => 'customer@example.com', +]); +``` + +### Managing Accounts (Integrator only) + +Use the `accounts` service for account creation and management: + +```php +// Create a child account +$created = $client->accounts->create([ + 'Account' => [ + 'firstname' => '-', + 'lastname' => '-', + 'email' => 'customer@example.com', + 'password' => 'securepassword', + 'creatorRef' => 'customer-123', + ], + 'ApiKeys' => ['development', 'production'], + 'Tags' => ['plan' => 'pro'], +]); + +// Update a child account (targeted by email) +$updated = $client->accounts->update([ + 'email' => 'new.email@example.com', +], opts: [ + 'child_account_by_email' => 'customer@example.com', +]); + +// Suspend a child account +$client->accounts->setState('suspended', opts: [ + 'child_account_by_id' => 123, +]); + +// Delete a child account +$client->accounts->delete(opts: [ + 'child_account_by_creator_ref' => 'customer-123', +]); +``` + ## Pagination Params For requests that can be paginated, here are the supported array key values that can be sent through with a `$params` argument: diff --git a/src/Api/PrintNode/PrintNodeApiRequestor.php b/src/Api/PrintNode/PrintNodeApiRequestor.php index 4006045..f99bfb1 100644 --- a/src/Api/PrintNode/PrintNodeApiRequestor.php +++ b/src/Api/PrintNode/PrintNodeApiRequestor.php @@ -34,7 +34,7 @@ public function __construct( $this->apiBase = $apiBase; } - public function request(string $method, string $url, array $params = [], ?array $headers = []): array + public function request(string $method, string $url, array|string $params = [], ?array $headers = []): array { [$absoluteUrl, $headers, $params, $apiKey] = $this->prepareRequest($method, $url, $params, $headers); @@ -48,10 +48,21 @@ public function request(string $method, string $url, array $params = [], ?array $client = $this->httpClient()->withHeaders($headers); + // Send JSON for write operations when params are arrays + if (in_array(strtolower($method), ['post', 'put', 'patch'], true) && is_array($params)) { + $client = $client->asJson(); + } + $response = match (strtolower($method)) { - 'get' => $client->get($absoluteUrl, $params), + 'get' => $client->get($absoluteUrl, is_array($params) ? $params : []), 'post' => $client->post($absoluteUrl, $params), - 'delete' => $client->delete($absoluteUrl, $params), + 'delete' => $client->delete($absoluteUrl, is_array($params) ? $params : []), + 'put' => is_string($params) + ? $client->withBody($params, 'application/json')->send('PUT', $absoluteUrl) + : $client->put($absoluteUrl, $params), + 'patch' => is_string($params) + ? $client->withBody($params, 'application/json')->send('PATCH', $absoluteUrl) + : $client->patch($absoluteUrl, $params), }; $body = $this->interpretResponse($response); @@ -160,6 +171,11 @@ private function interpretResponse(Response $response): array|int ); } + // Some endpoints return no content (e.g., 204) - normalize to empty array + if ($response->status() === 204) { + return []; + } + return $response->json(); } diff --git a/src/Api/PrintNode/PrintNodeClient.php b/src/Api/PrintNode/PrintNodeClient.php index 9eedbf4..8720cc8 100644 --- a/src/Api/PrintNode/PrintNodeClient.php +++ b/src/Api/PrintNode/PrintNodeClient.php @@ -13,6 +13,7 @@ * @property-read \Rawilk\Printing\Api\PrintNode\Service\PrinterService $printers * @property-read \Rawilk\Printing\Api\PrintNode\Service\PrintJobService $printJobs * @property-read \Rawilk\Printing\Api\PrintNode\Service\WhoamiService $whoami + * @property-read \Rawilk\Printing\Api\PrintNode\Service\AccountService $accounts */ class PrintNodeClient extends BasePrintNodeClient { diff --git a/src/Api/PrintNode/Service/AccountService.php b/src/Api/PrintNode/Service/AccountService.php new file mode 100644 index 0000000..b3f2bad --- /dev/null +++ b/src/Api/PrintNode/Service/AccountService.php @@ -0,0 +1,48 @@ +, ApiKeys?: array, Tags?: array} $params + */ + public function create(array $params, null|array|RequestOptions $opts = null): Whoami + { + return $this->request('post', '/account', $params, opts: $opts, expectedResource: Whoami::class); + } + + /** + * Update a child account's profile (target via child account headers in $opts). + */ + public function update(array $params, null|array|RequestOptions $opts = null): Whoami + { + return $this->request('patch', '/account', $params, opts: $opts, expectedResource: Whoami::class); + } + + /** + * Set account state for a child account. Send body as a JSON string: "active" or "suspended". + */ + public function setState(string $state, null|array|RequestOptions $opts = null): array + { + // The API expects a raw JSON string body, e.g., "active" or "suspended" + return $this->request('put', '/account/state', json_encode($state, JSON_THROW_ON_ERROR), opts: $opts); + } + + /** + * Delete a child account. Target via child account headers in $opts. + * Returns an array of affected IDs per API conventions. + */ + public function delete(null|array|RequestOptions $opts = null): array + { + return $this->request('delete', '/account', [], opts: $opts); + } +} + diff --git a/src/Api/PrintNode/Service/ServiceFactory.php b/src/Api/PrintNode/Service/ServiceFactory.php index 3973a30..c0ea6b8 100644 --- a/src/Api/PrintNode/Service/ServiceFactory.php +++ b/src/Api/PrintNode/Service/ServiceFactory.php @@ -17,6 +17,7 @@ * @property-read \Rawilk\Printing\Api\PrintNode\Service\PrinterService $printers * @property-read \Rawilk\Printing\Api\PrintNode\Service\PrintJobService $printJobs * @property-read \Rawilk\Printing\Api\PrintNode\Service\WhoamiService $whoami + * @property-read \Rawilk\Printing\Api\PrintNode\Service\AccountService $accounts */ class ServiceFactory { @@ -27,6 +28,7 @@ class ServiceFactory 'printers' => PrinterService::class, 'printJobs' => PrintJobService::class, 'whoami' => WhoamiService::class, + 'accounts' => AccountService::class, ]; public function __construct(protected PrintNodeClientInterface $client) diff --git a/src/Api/PrintNode/Util/RequestOptions.php b/src/Api/PrintNode/Util/RequestOptions.php index f90e7c8..c8064a6 100644 --- a/src/Api/PrintNode/Util/RequestOptions.php +++ b/src/Api/PrintNode/Util/RequestOptions.php @@ -73,6 +73,22 @@ public static function parse(RequestOptions|array|string|null $options, bool $st unset($options['api_base']); } + // Integrator/Child Account impersonation headers + if (array_key_exists('child_account_by_id', $options)) { + $headers['X-Child-Account-By-Id'] = (string) $options['child_account_by_id']; + unset($options['child_account_by_id']); + } + + if (array_key_exists('child_account_by_email', $options)) { + $headers['X-Child-Account-By-Email'] = (string) $options['child_account_by_email']; + unset($options['child_account_by_email']); + } + + if (array_key_exists('child_account_by_creator_ref', $options)) { + $headers['X-Child-Account-By-CreatorRef'] = (string) $options['child_account_by_creator_ref']; + unset($options['child_account_by_creator_ref']); + } + if ($strict && ! empty($options)) { $message = 'Got unexpected keys in options array: ' . implode(', ', array_keys($options)); diff --git a/tests/Feature/Api/PrintNode/Service/AccountServiceTest.php b/tests/Feature/Api/PrintNode/Service/AccountServiceTest.php new file mode 100644 index 0000000..80837dc --- /dev/null +++ b/tests/Feature/Api/PrintNode/Service/AccountServiceTest.php @@ -0,0 +1,84 @@ +fakeRequests(); + + $client = new PrintNodeClient(['api_key' => 'my-key']); + $this->service = new AccountService($client); +}); + +it('creates a child account', function () { + $this->fakeRequest('whoami'); + + $result = $this->service->create([ + 'Account' => [ + 'firstname' => '-', + 'lastname' => '-', + 'email' => 'child@example.com', + 'password' => 'password1234', + 'creatorRef' => 'child-1', + ], + 'ApiKeys' => ['dev'], + 'Tags' => ['tier' => 'premium'], + ]); + + expect($result)->toBeInstanceOf(Whoami::class); +}); + +it('updates a child account with headers', function () { + $this->fakeRequest('whoami', expectation: function (Request $request) { + expect($request->method())->toBe('PATCH') + ->and($request->header('X-Child-Account-By-Email'))->toBe(['child@example.com']); + }); + + $result = $this->service->update([ + 'email' => 'new.email@example.com', + ], opts: [ + 'child_account_by_email' => 'child@example.com', + ]); + + expect($result)->toBeInstanceOf(Whoami::class); +}); + +it('sets account state with raw body', function () { + $this->fakeRequest(function () { + return []; + }, code: 204, expectation: function (Request $request) { + expect($request->method())->toBe('PUT') + ->and($request->url())->toEndWith('/account/state') + ->and($request->body())->toBe('"suspended"'); + }); + + $response = $this->service->setState('suspended', opts: [ + 'child_account_by_id' => 123, + ]); + + expect($response)->toBeArray()->toBeEmpty(); +}); + +it('deletes a child account', function () { + $this->fakeRequest(function () { + return ['affected' => [123]]; + }, expectation: function (Request $request) { + expect($request->method())->toBe('DELETE'); + }); + + $response = $this->service->delete(opts: [ + 'child_account_by_creator_ref' => 'child-1', + ]); + + expect($response)->toBeArray(); +}); + diff --git a/tests/Feature/Api/PrintNode/Service/ServiceFactoryTest.php b/tests/Feature/Api/PrintNode/Service/ServiceFactoryTest.php index 3cf15b8..403ecfd 100644 --- a/tests/Feature/Api/PrintNode/Service/ServiceFactoryTest.php +++ b/tests/Feature/Api/PrintNode/Service/ServiceFactoryTest.php @@ -4,6 +4,7 @@ use Rawilk\Printing\Api\PrintNode\PrintNodeClient; use Rawilk\Printing\Api\PrintNode\Service\ServiceFactory; +use Rawilk\Printing\Api\PrintNode\Service\AccountService; use Rawilk\Printing\Api\PrintNode\Service\WhoamiService; beforeEach(function () { @@ -12,7 +13,8 @@ }); it('exposes properties for services', function () { - expect($this->serviceFactory->whoami)->toBeInstanceOf(WhoamiService::class); + expect($this->serviceFactory->whoami)->toBeInstanceOf(WhoamiService::class) + ->and($this->serviceFactory->accounts)->toBeInstanceOf(AccountService::class); }); test('multiple calls return the same instance', function () { diff --git a/tests/Feature/Api/PrintNode/Util/RequestOptionsTest.php b/tests/Feature/Api/PrintNode/Util/RequestOptionsTest.php index d4546fb..75dec70 100644 --- a/tests/Feature/Api/PrintNode/Util/RequestOptionsTest.php +++ b/tests/Feature/Api/PrintNode/Util/RequestOptionsTest.php @@ -100,6 +100,23 @@ ->apiBase->toBe('https://example.com'); }); +it('parses child account header options', function () { + $opts = RequestOptions::parse([ + 'child_account_by_id' => 123, + 'child_account_by_email' => 'child@example.com', + 'child_account_by_creator_ref' => 'child-ref', + ]); + + expect($opts) + ->apiKey->toBeNull() + ->headers->toEqualCanonicalizing([ + 'X-Child-Account-By-Id' => '123', + 'X-Child-Account-By-Email' => 'child@example.com', + 'X-Child-Account-By-CreatorRef' => 'child-ref', + ]) + ->apiBase->toBeNull(); +}); + it('can merge options', function () { $baseOpts = RequestOptions::parse([ 'api_key' => 'foo',