From aba95e4e962734ca0c0ba0138f56379901ca6bc9 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Thu, 30 Apr 2026 15:12:56 -0400 Subject: [PATCH 1/4] Add publishTemplate endpoint; refactor signed-request handling - Extract executeSignedRequest + decodeResponse from makeRequest - Add VERSION constant ('1.2.0'); replace hardcoded User-Agent - Add postNoBody for endpoints that sign and send literal '{}' body - Add Console::publishTemplate + PublishTemplateResult DTO - Tests assert body, signature, and absence of sig_payload query --- src/AccessGridClient.php | 71 +++++++++++++++------- src/Models/PublishTemplateResult.php | 15 +++++ src/Services/Console.php | 10 +++ tests/Models/PublishTemplateResultTest.php | 38 ++++++++++++ tests/Services/ConsoleTest.php | 27 ++++++++ 5 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 src/Models/PublishTemplateResult.php create mode 100644 tests/Models/PublishTemplateResultTest.php diff --git a/src/AccessGridClient.php b/src/AccessGridClient.php index f5daca1..5bc3e57 100644 --- a/src/AccessGridClient.php +++ b/src/AccessGridClient.php @@ -11,6 +11,8 @@ class AccessGridClient { + public const VERSION = '1.2.0'; + private string $accountId; private string $secretKey; private string $baseUrl; @@ -105,44 +107,58 @@ public function makeRequest(string $method, string $endpoint, ?array $data = nul $payload = !empty($data) ? json_encode($data) : ''; } - // Generate signature - $signature = $this->generateSignature($payload); - - $headers = [ - 'X-ACCT-ID: ' . $this->accountId, - 'X-PAYLOAD-SIG: ' . $signature, - 'Content-Type: application/json', - 'User-Agent: accessgrid-php @ v1.0.0' - ]; - // For requests with empty bodies (GET or action endpoints like unlink/suspend/resume), - // we need to include the sig_payload parameter + // include the ID payload in sig_payload query param if ($method === 'GET' || $method === 'DELETE' || ($method === 'POST' && empty($data))) { if ($params === null) { $params = []; } - // Include the ID payload in the query params if ($resourceId) { $params['sig_payload'] = json_encode(['id' => $resourceId]); } } - // Build final URL with query parameters - $finalUrl = $url; - if (!empty($params)) { - $queryString = http_build_query($params); - $separator = strpos($url, '?') !== false ? '&' : '?'; - $finalUrl = $url . $separator . $queryString; - } - // Build request body for POST/PUT/PATCH $requestBody = null; if (!empty($data) && $method !== 'GET') { $requestBody = json_encode($data); } - // Delegate to HTTP client - $response = $this->httpClient->send($method, $finalUrl, $headers, $requestBody); + return $this->executeSignedRequest($method, $url, $requestBody, $payload, $params ?? []); + } + + /** + * Sign a payload, build headers, send the request, and decode the response. + * Single source of truth for the wire-level signed-request behavior. + */ + private function executeSignedRequest( + string $method, + string $url, + ?string $body, + string $payloadToSign, + array $params = [] + ): array { + $signature = $this->generateSignature($payloadToSign); + + $headers = [ + 'X-ACCT-ID: ' . $this->accountId, + 'X-PAYLOAD-SIG: ' . $signature, + 'Content-Type: application/json', + 'User-Agent: accessgrid-php @ v' . self::VERSION, + ]; + + $finalUrl = $url; + if (!empty($params)) { + $separator = strpos($url, '?') !== false ? '&' : '?'; + $finalUrl = $url . $separator . http_build_query($params); + } + + $response = $this->httpClient->send($method, $finalUrl, $headers, $body); + return $this->decodeResponse($response); + } + + private function decodeResponse(\AccessGrid\Http\HttpResponse $response): array + { $httpCode = $response->getStatusCode(); $responseBody = $response->getBody(); @@ -168,6 +184,17 @@ public function makeRequest(string $method, string $endpoint, ?array $data = nul return $decoded; } + /** + * POST to an endpoint that accepts no request body. + * Per docs: sign and send '{}' (literal empty JSON object). + * Distinct from action-on-resource POSTs (suspend/resume/unlink/delete) which + * sign {"id": resourceId} via the sig_payload query param. + */ + public function postNoBody(string $endpoint): array + { + return $this->executeSignedRequest('POST', $this->baseUrl . $endpoint, '{}', '{}'); + } + public function get(string $endpoint, ?array $params = null): array { return $this->makeRequest('GET', $endpoint, null, $params); diff --git a/src/Models/PublishTemplateResult.php b/src/Models/PublishTemplateResult.php new file mode 100644 index 0000000..9f464c1 --- /dev/null +++ b/src/Models/PublishTemplateResult.php @@ -0,0 +1,15 @@ +id = $data['id'] ?? null; + $this->status = $data['status'] ?? null; + } +} diff --git a/src/Services/Console.php b/src/Services/Console.php index b6cb219..ab27f5e 100644 --- a/src/Services/Console.php +++ b/src/Services/Console.php @@ -9,6 +9,7 @@ use AccessGrid\Models\LandingPage; use AccessGrid\Models\CredentialProfile; use AccessGrid\Models\Webhook; +use AccessGrid\Models\PublishTemplateResult; class Console { @@ -53,6 +54,15 @@ public function readTemplate(array $data): Template return new Template($this->client, $response); } + /** + * Publish a card template + */ + public function publishTemplate(string $templateId): PublishTemplateResult + { + $response = $this->client->postNoBody("/v1/console/card-templates/{$templateId}/publish"); + return new PublishTemplateResult($response); + } + /** * Get event logs for a card template */ diff --git a/tests/Models/PublishTemplateResultTest.php b/tests/Models/PublishTemplateResultTest.php new file mode 100644 index 0000000..35e2c38 --- /dev/null +++ b/tests/Models/PublishTemplateResultTest.php @@ -0,0 +1,38 @@ + 'tmpl_123', + 'status' => 'in-review', + ]); + + $this->assertEquals('tmpl_123', $result->id); + $this->assertEquals('in-review', $result->status); + } + + public function testConstructionWithReadyStatus(): void + { + $result = new PublishTemplateResult([ + 'id' => 'tmpl_456', + 'status' => 'ready', + ]); + + $this->assertEquals('ready', $result->status); + } + + public function testConstructionWithEmptyData(): void + { + $result = new PublishTemplateResult([]); + + $this->assertNull($result->id); + $this->assertNull($result->status); + } +} diff --git a/tests/Services/ConsoleTest.php b/tests/Services/ConsoleTest.php index 44d85b8..96cc5b1 100644 --- a/tests/Services/ConsoleTest.php +++ b/tests/Services/ConsoleTest.php @@ -10,6 +10,7 @@ use AccessGrid\Models\LedgerItemAccessPass; use AccessGrid\Models\LedgerItemPassTemplate; use AccessGrid\Models\Webhook; +use AccessGrid\Models\PublishTemplateResult; class ConsoleTest extends TestCase { @@ -81,6 +82,32 @@ public function testReadTemplate(): void $this->assertEquals('#FFFFFF', $template->styleSettings['background_color']); } + public function testPublishTemplate(): void + { + $this->expectRequest('POST', '/v1/console/card-templates/tmpl_123/publish', 200, [ + 'id' => 'tmpl_123', + 'status' => 'in-review', + ]); + + $result = $this->client->console->publishTemplate('tmpl_123'); + + $this->assertInstanceOf(PublishTemplateResult::class, $result); + $this->assertEquals('tmpl_123', $result->id); + $this->assertEquals('in-review', $result->status); + } + + public function testPublishTemplateAndroidReady(): void + { + $this->expectRequest('POST', '/v1/console/card-templates/tmpl_456/publish', 200, [ + 'id' => 'tmpl_456', + 'status' => 'ready', + ]); + + $result = $this->client->console->publishTemplate('tmpl_456'); + + $this->assertEquals('ready', $result->status); + } + public function testGetLogs(): void { $this->expectRequest('GET', '/v1/console/card-templates/tmpl_123/logs', 200, [ From 259939eb538ff414584ef05a02076fdebcfe9ccd Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Thu, 30 Apr 2026 17:30:40 -0400 Subject: [PATCH 2/4] expose credential_profiles and landing_pages on Template --- src/Models/Template.php | 8 +++ tests/Models/TemplateTest.php | 36 +++++++++++ tests/Services/ConsoleTest.php | 106 +++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+) diff --git a/src/Models/Template.php b/src/Models/Template.php index 9f5a36e..bb63df5 100644 --- a/src/Models/Template.php +++ b/src/Models/Template.php @@ -21,6 +21,8 @@ class Template public ?array $termsSettings; public ?array $styleSettings; public ?array $metadata; + public ?array $credentialProfiles; + public ?array $landingPages; // Convenience accessors (snake_case aliases) public ?string $use_case; @@ -28,6 +30,8 @@ class Template public ?string $last_published_at; public ?int $issued_keys_count; public ?int $active_keys_count; + public ?array $credential_profiles; + public ?array $landing_pages; // Convenience accessors extracted from nested objects public ?bool $allow_on_multiple_devices; @@ -51,6 +55,8 @@ public function __construct(AccessGridClient $client, array $data) $this->termsSettings = $data['terms_settings'] ?? null; $this->styleSettings = $data['style_settings'] ?? null; $this->metadata = $data['metadata'] ?? null; + $this->credentialProfiles = $data['credential_profiles'] ?? null; + $this->landingPages = $data['landing_pages'] ?? null; // snake_case aliases $this->use_case = $this->useCase; @@ -58,6 +64,8 @@ public function __construct(AccessGridClient $client, array $data) $this->last_published_at = $this->lastPublishedAt; $this->issued_keys_count = $this->issuedKeysCount; $this->active_keys_count = $this->activeKeysCount; + $this->credential_profiles = $this->credentialProfiles; + $this->landing_pages = $this->landingPages; // Convenience accessors from nested objects $this->allow_on_multiple_devices = $this->allowedDeviceCounts['allow_on_multiple_devices'] ?? null; diff --git a/tests/Models/TemplateTest.php b/tests/Models/TemplateTest.php index dbaa5c2..dc69fc1 100644 --- a/tests/Models/TemplateTest.php +++ b/tests/Models/TemplateTest.php @@ -103,4 +103,40 @@ public function testNullableArrayProperties(): void $this->assertNull($template->termsSettings); $this->assertNull($template->styleSettings); } + + public function testCredentialProfilesAndLandingPagesPresent(): void + { + $template = new Template($this->client, [ + 'credential_profiles' => ['cp_1', 'cp_2'], + 'landing_pages' => ['lp_1'], + ]); + + $this->assertEquals(['cp_1', 'cp_2'], $template->credentialProfiles); + $this->assertEquals(['lp_1'], $template->landingPages); + + // snake_case aliases + $this->assertEquals(['cp_1', 'cp_2'], $template->credential_profiles); + $this->assertEquals(['lp_1'], $template->landing_pages); + } + + public function testCredentialProfilesAndLandingPagesEmpty(): void + { + $template = new Template($this->client, [ + 'credential_profiles' => [], + 'landing_pages' => [], + ]); + + $this->assertEquals([], $template->credentialProfiles); + $this->assertEquals([], $template->landingPages); + } + + public function testCredentialProfilesAndLandingPagesAbsent(): void + { + $template = new Template($this->client, []); + + $this->assertNull($template->credentialProfiles); + $this->assertNull($template->landingPages); + $this->assertNull($template->credential_profiles); + $this->assertNull($template->landing_pages); + } } diff --git a/tests/Services/ConsoleTest.php b/tests/Services/ConsoleTest.php index 96cc5b1..d325c9c 100644 --- a/tests/Services/ConsoleTest.php +++ b/tests/Services/ConsoleTest.php @@ -108,6 +108,112 @@ public function testPublishTemplateAndroidReady(): void $this->assertEquals('ready', $result->status); } + public function testPublishTemplateSignsEmptyJsonObject(): void + { + // Per docs: publish has no body; sign and send '{}' literally. + // Distinct from suspend/resume/unlink/delete which use sig_payload={"id":"..."}. + $expectedSig = hash_hmac('sha256', base64_encode('{}'), 'test-secret-key'); + + $this->mockHttpClient + ->expects($this->once()) + ->method('send') + ->with( + $this->equalTo('POST'), + $this->logicalAnd( + $this->stringContains('/v1/console/card-templates/tmpl_123/publish'), + $this->logicalNot($this->stringContains('sig_payload=')) + ), + $this->callback(function (array $headers) use ($expectedSig) { + return in_array('X-PAYLOAD-SIG: ' . $expectedSig, $headers, true); + }), + $this->equalTo('{}') + ) + ->willReturn(new \AccessGrid\Http\HttpResponse(200, json_encode([ + 'id' => 'tmpl_123', + 'status' => 'in-review', + ]))); + + $this->client->console->publishTemplate('tmpl_123'); + } + + public function testCreateTemplateWithCredentialProfilesAndLandingPages(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('send') + ->with( + $this->equalTo('POST'), + $this->stringContains('/v1/console/card-templates'), + $this->isType('array'), + $this->callback(function ($body) { + $decoded = json_decode($body, true); + return is_array($decoded) + && ($decoded['credential_profiles'] ?? null) === ['cp_1', 'cp_2'] + && ($decoded['landing_pages'] ?? null) === ['lp_1']; + }) + ) + ->willReturn(new \AccessGrid\Http\HttpResponse(200, json_encode([ + 'id' => 'tmpl_123', + 'name' => 'Employee Badge', + ]))); + + $this->client->console->createTemplate([ + 'name' => 'Employee Badge', + 'platform' => 'apple', + 'credential_profiles' => ['cp_1', 'cp_2'], + 'landing_pages' => ['lp_1'], + ]); + } + + public function testUpdateTemplateWithCredentialProfilesAndLandingPages(): void + { + $this->mockHttpClient + ->expects($this->once()) + ->method('send') + ->with( + $this->equalTo('PUT'), + $this->stringContains('/v1/console/card-templates/tmpl_123'), + $this->isType('array'), + $this->callback(function ($body) { + $decoded = json_decode($body, true); + return is_array($decoded) + && ($decoded['credential_profiles'] ?? null) === ['cp_1'] + && ($decoded['landing_pages'] ?? null) === [] + && !isset($decoded['card_template_id']); + }) + ) + ->willReturn(new \AccessGrid\Http\HttpResponse(200, json_encode([ + 'id' => 'tmpl_123', + 'name' => 'Updated Badge', + ]))); + + $this->client->console->updateTemplate([ + 'card_template_id' => 'tmpl_123', + 'name' => 'Updated Badge', + 'credential_profiles' => ['cp_1'], + 'landing_pages' => [], + ]); + } + + public function testReadTemplateExposesCredentialProfilesAndLandingPages(): void + { + $this->expectRequest('GET', '/v1/console/card-templates/tmpl_123', 200, [ + 'id' => 'tmpl_123', + 'name' => 'Employee Badge', + 'credential_profiles' => ['cp_1', 'cp_2'], + 'landing_pages' => ['lp_1'], + ]); + + $template = $this->client->console->readTemplate([ + 'card_template_id' => 'tmpl_123', + ]); + + $this->assertEquals(['cp_1', 'cp_2'], $template->credentialProfiles); + $this->assertEquals(['lp_1'], $template->landingPages); + $this->assertEquals(['cp_1', 'cp_2'], $template->credential_profiles); + $this->assertEquals(['lp_1'], $template->landing_pages); + } + public function testGetLogs(): void { $this->expectRequest('GET', '/v1/console/card-templates/tmpl_123/logs', 200, [ From 8622de5af961522373075219e9af548264071e48 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Thu, 30 Apr 2026 17:32:39 -0400 Subject: [PATCH 3/4] Update README.md --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 29b0fd0..c9e23a5 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,7 @@ MIT License | GET /v1/console/card-template-pairs | `console->listPassTemplatePairs()` | Y | | POST /v1/console/card-template-pairs | `console->createPassTemplatePair()` | Y | | POST /v1/console/card-templates/{id}/ios_preflight | `console->iosPreflight()` | Y | +| POST /v1/console/card-templates/{id}/publish | `console->publishTemplate()` | Y | | GET /v1/console/ledger-items | `console->ledgerItems()` | Y | | GET /v1/console/landing-pages | `console->listLandingPages()` | Y | | POST /v1/console/landing-pages | `console->createLandingPage()` | Y | From 3993a880b3528be754dd79a8b0f3f849f49c4711 Mon Sep 17 00:00:00 2001 From: Chet Bortz Date: Fri, 1 May 2026 14:02:57 -0400 Subject: [PATCH 4/4] publishTemplate: send card_template_id body - drops DTO - drops postNoBody --- src/AccessGridClient.php | 11 ------- src/Models/PublishTemplateResult.php | 15 --------- src/Services/Console.php | 11 ++++--- tests/Models/PublishTemplateResultTest.php | 38 ---------------------- tests/Services/ConsoleTest.php | 19 +++++------ 5 files changed, 16 insertions(+), 78 deletions(-) delete mode 100644 src/Models/PublishTemplateResult.php delete mode 100644 tests/Models/PublishTemplateResultTest.php diff --git a/src/AccessGridClient.php b/src/AccessGridClient.php index 5bc3e57..658b937 100644 --- a/src/AccessGridClient.php +++ b/src/AccessGridClient.php @@ -184,17 +184,6 @@ private function decodeResponse(\AccessGrid\Http\HttpResponse $response): array return $decoded; } - /** - * POST to an endpoint that accepts no request body. - * Per docs: sign and send '{}' (literal empty JSON object). - * Distinct from action-on-resource POSTs (suspend/resume/unlink/delete) which - * sign {"id": resourceId} via the sig_payload query param. - */ - public function postNoBody(string $endpoint): array - { - return $this->executeSignedRequest('POST', $this->baseUrl . $endpoint, '{}', '{}'); - } - public function get(string $endpoint, ?array $params = null): array { return $this->makeRequest('GET', $endpoint, null, $params); diff --git a/src/Models/PublishTemplateResult.php b/src/Models/PublishTemplateResult.php deleted file mode 100644 index 9f464c1..0000000 --- a/src/Models/PublishTemplateResult.php +++ /dev/null @@ -1,15 +0,0 @@ -id = $data['id'] ?? null; - $this->status = $data['status'] ?? null; - } -} diff --git a/src/Services/Console.php b/src/Services/Console.php index ab27f5e..e231364 100644 --- a/src/Services/Console.php +++ b/src/Services/Console.php @@ -9,7 +9,6 @@ use AccessGrid\Models\LandingPage; use AccessGrid\Models\CredentialProfile; use AccessGrid\Models\Webhook; -use AccessGrid\Models\PublishTemplateResult; class Console { @@ -57,10 +56,14 @@ public function readTemplate(array $data): Template /** * Publish a card template */ - public function publishTemplate(string $templateId): PublishTemplateResult + public function publishTemplate(array $data): object { - $response = $this->client->postNoBody("/v1/console/card-templates/{$templateId}/publish"); - return new PublishTemplateResult($response); + $templateId = $data['card_template_id']; + $response = $this->client->post( + "/v1/console/card-templates/{$templateId}/publish", + ['card_template_id' => $templateId] + ); + return (object) $response; } /** diff --git a/tests/Models/PublishTemplateResultTest.php b/tests/Models/PublishTemplateResultTest.php deleted file mode 100644 index 35e2c38..0000000 --- a/tests/Models/PublishTemplateResultTest.php +++ /dev/null @@ -1,38 +0,0 @@ - 'tmpl_123', - 'status' => 'in-review', - ]); - - $this->assertEquals('tmpl_123', $result->id); - $this->assertEquals('in-review', $result->status); - } - - public function testConstructionWithReadyStatus(): void - { - $result = new PublishTemplateResult([ - 'id' => 'tmpl_456', - 'status' => 'ready', - ]); - - $this->assertEquals('ready', $result->status); - } - - public function testConstructionWithEmptyData(): void - { - $result = new PublishTemplateResult([]); - - $this->assertNull($result->id); - $this->assertNull($result->status); - } -} diff --git a/tests/Services/ConsoleTest.php b/tests/Services/ConsoleTest.php index d325c9c..609b65a 100644 --- a/tests/Services/ConsoleTest.php +++ b/tests/Services/ConsoleTest.php @@ -10,7 +10,6 @@ use AccessGrid\Models\LedgerItemAccessPass; use AccessGrid\Models\LedgerItemPassTemplate; use AccessGrid\Models\Webhook; -use AccessGrid\Models\PublishTemplateResult; class ConsoleTest extends TestCase { @@ -89,9 +88,9 @@ public function testPublishTemplate(): void 'status' => 'in-review', ]); - $result = $this->client->console->publishTemplate('tmpl_123'); + $result = $this->client->console->publishTemplate(['card_template_id' => 'tmpl_123']); - $this->assertInstanceOf(PublishTemplateResult::class, $result); + $this->assertIsObject($result); $this->assertEquals('tmpl_123', $result->id); $this->assertEquals('in-review', $result->status); } @@ -103,16 +102,16 @@ public function testPublishTemplateAndroidReady(): void 'status' => 'ready', ]); - $result = $this->client->console->publishTemplate('tmpl_456'); + $result = $this->client->console->publishTemplate(['card_template_id' => 'tmpl_456']); $this->assertEquals('ready', $result->status); } - public function testPublishTemplateSignsEmptyJsonObject(): void + public function testPublishTemplateSignsCardTemplateIdPayload(): void { - // Per docs: publish has no body; sign and send '{}' literally. - // Distinct from suspend/resume/unlink/delete which use sig_payload={"id":"..."}. - $expectedSig = hash_hmac('sha256', base64_encode('{}'), 'test-secret-key'); + // Per API convention: action endpoints send {"": ""} body and sign it. + $expectedBody = '{"card_template_id":"tmpl_123"}'; + $expectedSig = hash_hmac('sha256', base64_encode($expectedBody), 'test-secret-key'); $this->mockHttpClient ->expects($this->once()) @@ -126,14 +125,14 @@ public function testPublishTemplateSignsEmptyJsonObject(): void $this->callback(function (array $headers) use ($expectedSig) { return in_array('X-PAYLOAD-SIG: ' . $expectedSig, $headers, true); }), - $this->equalTo('{}') + $this->equalTo($expectedBody) ) ->willReturn(new \AccessGrid\Http\HttpResponse(200, json_encode([ 'id' => 'tmpl_123', 'status' => 'in-review', ]))); - $this->client->console->publishTemplate('tmpl_123'); + $this->client->console->publishTemplate(['card_template_id' => 'tmpl_123']); } public function testCreateTemplateWithCredentialProfilesAndLandingPages(): void