From bee87a14427bedb9fce714fcd508853d468af213 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:15:05 +0200 Subject: [PATCH 01/16] Add support for listing SLA addons (GET /v2/addon/slas/) --- src/HypernodeClient.php | 3 ++ src/Service/Addon.php | 29 +++++++++++++++ tests/unit/Service/AddonTest.php | 62 ++++++++++++++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 src/Service/Addon.php create mode 100644 tests/unit/Service/AddonTest.php diff --git a/src/HypernodeClient.php b/src/HypernodeClient.php index 0774f76..2a9499f 100644 --- a/src/HypernodeClient.php +++ b/src/HypernodeClient.php @@ -7,6 +7,7 @@ use Http\Client\Common\HttpMethodsClientInterface; use Hypernode\Api\Exception\HypernodeApiClientException; use Hypernode\Api\Exception\HypernodeApiServerException; +use Hypernode\Api\Service\Addon; use Hypernode\Api\Service\App; use Hypernode\Api\Service\BrancherApp; use Hypernode\Api\Service\Logbook; @@ -16,6 +17,7 @@ class HypernodeClient { public HttpMethodsClientInterface $api; + public Addon $addon; public App $app; public BrancherApp $brancherApp; public Settings $settings; @@ -24,6 +26,7 @@ class HypernodeClient public function __construct(HttpMethodsClientInterface $apiClient) { $this->api = $apiClient; + $this->addon = new Addon($this); $this->app = new App($this); $this->brancherApp = new BrancherApp($this); $this->settings = new Settings($this); diff --git a/src/Service/Addon.php b/src/Service/Addon.php new file mode 100644 index 0000000..11cdd28 --- /dev/null +++ b/src/Service/Addon.php @@ -0,0 +1,29 @@ +client->api->get(self::V2_ADDON_SLA_LIST_URL); + + $this->client->maybeThrowApiExceptions($response); + + return $this->client->getJsonFromResponse($response); + } +} diff --git a/tests/unit/Service/AddonTest.php b/tests/unit/Service/AddonTest.php new file mode 100644 index 0000000..ec2c2a7 --- /dev/null +++ b/tests/unit/Service/AddonTest.php @@ -0,0 +1,62 @@ + 123, + 'code' => 'sla-standard', + 'name' => 'SLA Standard', + 'price' => 2500, + 'currency' => 'EUR', + 'billing_period' => 1, + 'billing_period_unit' => 'month', + ], + ]; + $this->responses->append( + new Response(200, [], json_encode($slas)), + ); + + $result = $this->client->addon->getSlas(); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/addon/slas/', $request->getUri()); + $this->assertEquals($slas, $result); + } + + public function testGetSlasRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->addon->getSlas(); + } + + public function testGetSlasRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->addon->getSlas(); + } +} From f73a56642a0ee51f8ae6df92ce87186bdf59c19f Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:15:49 +0200 Subject: [PATCH 02/16] Add support for listing block attack descriptions (GET /v2/app/block_attack_descriptions/) --- src/Service/App.php | 18 ++++++++++++++ tests/unit/Service/AppTest.php | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/Service/App.php b/src/Service/App.php index c2eac05..e29d2eb 100644 --- a/src/Service/App.php +++ b/src/Service/App.php @@ -12,6 +12,7 @@ class App extends AbstractService public const V2_BRANCHER_APP_URL = "/v2/brancher/app/%s/"; public const V2_BRANCHER_DETAIL_URL = "/v2/brancher/%s/"; public const V1_APP_FLOWS_URL = "/logbook/v1/logbooks/%s/flows/"; + public const V2_APP_BLOCK_ATTACK_DESCRIPTIONS_URL = "/v2/app/block_attack_descriptions/"; /** * @param array $params @@ -44,4 +45,21 @@ public function getList(array $params = []): array return $apps; } + + /** + * Show the available blocks that can be triggered using the block attack endpoint. + * + * @return array Map of attack names to their descriptions + * @throws \Http\Client\Exception + * @throws \Hypernode\Api\Exception\HypernodeApiClientException + * @throws \Hypernode\Api\Exception\HypernodeApiServerException + */ + public function getBlockAttackDescriptions(): array + { + $response = $this->client->api->get(self::V2_APP_BLOCK_ATTACK_DESCRIPTIONS_URL); + + $this->client->maybeThrowApiExceptions($response); + + return $this->client->getJsonFromResponse($response); + } } diff --git a/tests/unit/Service/AppTest.php b/tests/unit/Service/AppTest.php index 15edf05..4c4984b 100644 --- a/tests/unit/Service/AppTest.php +++ b/tests/unit/Service/AppTest.php @@ -5,6 +5,8 @@ namespace Hypernode\Api\Service; use GuzzleHttp\Psr7\Response; +use Hypernode\Api\Exception\HypernodeApiClientException; +use Hypernode\Api\Exception\HypernodeApiServerException; use Hypernode\Api\HypernodeClientTestCase; class AppTest extends HypernodeClientTestCase @@ -112,4 +114,46 @@ public function testGetListPaginates() $this->assertEquals('GET', $request->getMethod()); $this->assertEquals('https://api.hypernode.com/v2/app/?limit=1&offset=1', $request->getUri()); } + + public function testGetBlockAttackDescriptions() + { + $descriptions = [ + 'BlockSqliBruteForce' => 'Attempts to deploy NGINX rules to block suspected (blind) SQL injection attack', + 'BlockChinaBruteForce' => 'Attempts to deploy NGINX rules to block brute force attacks from China', + ]; + $this->responses->append( + new Response(200, [], json_encode($descriptions)), + ); + + $result = $this->client->app->getBlockAttackDescriptions(); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/app/block_attack_descriptions/', $request->getUri()); + $this->assertEquals($descriptions, $result); + } + + public function testGetBlockAttackDescriptionsRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->app->getBlockAttackDescriptions(); + } + + public function testGetBlockAttackDescriptionsRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->app->getBlockAttackDescriptions(); + } } From 49e852a368ae96b2726c9ad0af7d564c41379c44 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:16:14 +0200 Subject: [PATCH 03/16] Add support for listing EAV descriptions (GET /v2/app/eav_descriptions/) --- src/Service/App.php | 19 +++++++++++++++ tests/unit/Service/AppTest.php | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/Service/App.php b/src/Service/App.php index e29d2eb..1eb8349 100644 --- a/src/Service/App.php +++ b/src/Service/App.php @@ -13,6 +13,7 @@ class App extends AbstractService public const V2_BRANCHER_DETAIL_URL = "/v2/brancher/%s/"; public const V1_APP_FLOWS_URL = "/logbook/v1/logbooks/%s/flows/"; public const V2_APP_BLOCK_ATTACK_DESCRIPTIONS_URL = "/v2/app/block_attack_descriptions/"; + public const V2_APP_EAV_DESCRIPTIONS_URL = "/v2/app/eav_descriptions/"; /** * @param array $params @@ -62,4 +63,22 @@ public function getBlockAttackDescriptions(): array return $this->client->getJsonFromResponse($response); } + + /** + * Show the available EAVs (entity-attribute values) that can be changed + * using the settings service. + * + * @return array Map of setting names to their descriptions + * @throws \Http\Client\Exception + * @throws \Hypernode\Api\Exception\HypernodeApiClientException + * @throws \Hypernode\Api\Exception\HypernodeApiServerException + */ + public function getEavDescriptions(): array + { + $response = $this->client->api->get(self::V2_APP_EAV_DESCRIPTIONS_URL); + + $this->client->maybeThrowApiExceptions($response); + + return $this->client->getJsonFromResponse($response); + } } diff --git a/tests/unit/Service/AppTest.php b/tests/unit/Service/AppTest.php index 4c4984b..e6fdafb 100644 --- a/tests/unit/Service/AppTest.php +++ b/tests/unit/Service/AppTest.php @@ -156,4 +156,46 @@ public function testGetBlockAttackDescriptionsRaisesServerExceptions() $this->client->app->getBlockAttackDescriptions(); } + + public function testGetEavDescriptions() + { + $descriptions = [ + 'php_version' => 'The PHP version of the Hypernode', + 'nodejs_version' => 'The Node.js version of the Hypernode', + ]; + $this->responses->append( + new Response(200, [], json_encode($descriptions)), + ); + + $result = $this->client->app->getEavDescriptions(); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/app/eav_descriptions/', $request->getUri()); + $this->assertEquals($descriptions, $result); + } + + public function testGetEavDescriptionsRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->app->getEavDescriptions(); + } + + public function testGetEavDescriptionsRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->app->getEavDescriptions(); + } } From 0987c68e5c8760133c161214462f28408bdcf11f Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:16:39 +0200 Subject: [PATCH 04/16] Add support for ordering a new Hypernode (POST /v2/app/order/) --- src/Service/App.php | 18 ++++++++++++++ tests/unit/Service/AppTest.php | 44 ++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/Service/App.php b/src/Service/App.php index 1eb8349..ae91fa3 100644 --- a/src/Service/App.php +++ b/src/Service/App.php @@ -14,6 +14,7 @@ class App extends AbstractService public const V1_APP_FLOWS_URL = "/logbook/v1/logbooks/%s/flows/"; public const V2_APP_BLOCK_ATTACK_DESCRIPTIONS_URL = "/v2/app/block_attack_descriptions/"; public const V2_APP_EAV_DESCRIPTIONS_URL = "/v2/app/eav_descriptions/"; + public const V2_APP_ORDER_URL = "/v2/app/order/"; /** * @param array $params @@ -81,4 +82,21 @@ public function getEavDescriptions(): array return $this->client->getJsonFromResponse($response); } + + /** + * Order a new Hypernode under your account. + * + * @param array $data Order data, requires at least the keys + * `product_code`, `app_name` and `initial_app_configuration`. + * @return void + * @throws \Http\Client\Exception + * @throws \Hypernode\Api\Exception\HypernodeApiClientException + * @throws \Hypernode\Api\Exception\HypernodeApiServerException + */ + public function order(array $data): void + { + $response = $this->client->api->post(self::V2_APP_ORDER_URL, [], json_encode($data)); + + $this->client->maybeThrowApiExceptions($response); + } } diff --git a/tests/unit/Service/AppTest.php b/tests/unit/Service/AppTest.php index e6fdafb..f75edf6 100644 --- a/tests/unit/Service/AppTest.php +++ b/tests/unit/Service/AppTest.php @@ -198,4 +198,48 @@ public function testGetEavDescriptionsRaisesServerExceptions() $this->client->app->getEavDescriptions(); } + + public function testOrder() + { + $this->responses->append( + new Response(201, [], null), + ); + + $orderData = [ + 'product_code' => 'FALCON_M_202203', + 'app_name' => 'johndoe', + 'initial_app_configuration' => 'magento_2', + ]; + $this->client->app->order($orderData); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals('/v2/app/order/', $request->getUri()); + $this->assertJson((string)$request->getBody()); + $this->assertEquals($orderData, json_decode((string)$request->getBody(), true)); + } + + public function testOrderRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->app->order(['product_code' => 'FALCON_M_202203']); + } + + public function testOrderRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->app->order(['product_code' => 'FALCON_M_202203']); + } } From a5b807e1040e82d55c8d060d61397ec19c9e3256 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:17:16 +0200 Subject: [PATCH 05/16] Add support for listing backups (GET /v2/app/{app}/backup/) --- src/HypernodeClient.php | 3 + src/Service/Backup.php | 47 +++++++++++ tests/unit/Service/BackupTest.php | 134 ++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+) create mode 100644 src/Service/Backup.php create mode 100644 tests/unit/Service/BackupTest.php diff --git a/src/HypernodeClient.php b/src/HypernodeClient.php index 2a9499f..f4ac703 100644 --- a/src/HypernodeClient.php +++ b/src/HypernodeClient.php @@ -9,6 +9,7 @@ use Hypernode\Api\Exception\HypernodeApiServerException; use Hypernode\Api\Service\Addon; use Hypernode\Api\Service\App; +use Hypernode\Api\Service\Backup; use Hypernode\Api\Service\BrancherApp; use Hypernode\Api\Service\Logbook; use Hypernode\Api\Service\Settings; @@ -19,6 +20,7 @@ class HypernodeClient public HttpMethodsClientInterface $api; public Addon $addon; public App $app; + public Backup $backup; public BrancherApp $brancherApp; public Settings $settings; public Logbook $logbook; @@ -28,6 +30,7 @@ public function __construct(HttpMethodsClientInterface $apiClient) $this->api = $apiClient; $this->addon = new Addon($this); $this->app = new App($this); + $this->backup = new Backup($this); $this->brancherApp = new BrancherApp($this); $this->settings = new Settings($this); $this->logbook = new Logbook($this); diff --git a/src/Service/Backup.php b/src/Service/Backup.php new file mode 100644 index 0000000..068f8bf --- /dev/null +++ b/src/Service/Backup.php @@ -0,0 +1,47 @@ +client->api->get($requestUrl); + + $this->client->maybeThrowApiExceptions($response); + $data = $this->client->getJsonFromResponse($response); + + foreach ($data['results'] as $item) { + $backups[] = $item; + } + + $requestUrl = $data['next']; + } + + return $backups; + } +} diff --git a/tests/unit/Service/BackupTest.php b/tests/unit/Service/BackupTest.php new file mode 100644 index 0000000..fde2601 --- /dev/null +++ b/tests/unit/Service/BackupTest.php @@ -0,0 +1,134 @@ + 'backup-123', + 'type' => 'periodic', + 'backup_created_at' => '2026-07-14T01:00:00Z', + 'expired_at' => '2026-07-28T01:00:00Z', + ], + [ + 'backup_id' => 'backup-456', + 'type' => 'instant', + 'backup_created_at' => '2026-07-15T09:30:00Z', + 'expired_at' => '2026-07-29T09:30:00Z', + ], + ]; + $this->responses->append( + new Response(200, [], json_encode([ + 'count' => 2, + 'next' => null, + 'previous' => null, + 'results' => $backups, + ])), + ); + + $result = $this->client->backup->getList('johndoe'); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/app/johndoe/backup/', $request->getUri()); + $this->assertEquals($backups, $result); + } + + public function testGetListAcceptsParams() + { + $this->responses->append( + new Response(200, [], json_encode([ + 'count' => 0, + 'next' => null, + 'previous' => null, + 'results' => [], + ])), + ); + + $result = $this->client->backup->getList('johndoe', ['limit' => 10, 'offset' => 20]); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/app/johndoe/backup/?limit=10&offset=20', $request->getUri()); + $this->assertEquals([], $result); + } + + public function testGetListPaginates() + { + $this->responses->append( + new Response(200, [], json_encode([ + 'count' => 2, + 'next' => 'https://api.hypernode.com/v2/app/johndoe/backup/?limit=1&offset=1', + 'previous' => null, + 'results' => [ + [ + 'backup_id' => 'backup-123', + 'type' => 'periodic', + 'backup_created_at' => '2026-07-14T01:00:00Z', + 'expired_at' => '2026-07-28T01:00:00Z', + ], + ], + ])), + new Response(200, [], json_encode([ + 'count' => 2, + 'next' => null, + 'previous' => null, + 'results' => [ + [ + 'backup_id' => 'backup-456', + 'type' => 'instant', + 'backup_created_at' => '2026-07-15T09:30:00Z', + 'expired_at' => '2026-07-29T09:30:00Z', + ], + ], + ])), + ); + + $result = $this->client->backup->getList('johndoe'); + + $this->assertCount(2, $result); + $this->assertEquals('backup-123', $result[0]['backup_id']); + $this->assertEquals('backup-456', $result[1]['backup_id']); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals( + 'https://api.hypernode.com/v2/app/johndoe/backup/?limit=1&offset=1', + $request->getUri() + ); + } + + public function testGetListRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->backup->getList('johndoe'); + } + + public function testGetListRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->backup->getList('johndoe'); + } +} From ad5f69cc1495ac17306c9dccaea089666fd8d5ae Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:17:37 +0200 Subject: [PATCH 06/16] Add support for creating backups (POST /v2/app/{app}/backup/) --- src/Service/Backup.php | 20 ++++++++++++++++ tests/unit/Service/BackupTest.php | 38 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/Service/Backup.php b/src/Service/Backup.php index 068f8bf..3a80c2b 100644 --- a/src/Service/Backup.php +++ b/src/Service/Backup.php @@ -44,4 +44,24 @@ public function getList(string $app, array $params = []): array return $backups; } + + /** + * Create a new snapshot backup. This requires sla-standard to be enabled + * on the Hypernode. + * + * @param string $app Name of the Hypernode + * @return string Response message + * @throws HypernodeApiClientException + * @throws HypernodeApiServerException + */ + public function create(string $app): string + { + $url = sprintf(self::V2_APP_BACKUP_URL, $app); + + $response = $this->client->api->post($url); + + $this->client->maybeThrowApiExceptions($response); + + return $this->client->getJsonFromResponse($response); + } } diff --git a/tests/unit/Service/BackupTest.php b/tests/unit/Service/BackupTest.php index fde2601..f9ca735 100644 --- a/tests/unit/Service/BackupTest.php +++ b/tests/unit/Service/BackupTest.php @@ -131,4 +131,42 @@ public function testGetListRaisesServerExceptions() $this->client->backup->getList('johndoe'); } + + public function testCreate() + { + $this->responses->append( + new Response(200, [], json_encode('Backup creation initiated')), + ); + + $result = $this->client->backup->create('johndoe'); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals('/v2/app/johndoe/backup/', $request->getUri()); + $this->assertEquals('Backup creation initiated', $result); + } + + public function testCreateRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode( + 'Creating backups requires sla-standard to be enabled.' + )); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->backup->create('johndoe'); + } + + public function testCreateRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->backup->create('johndoe'); + } } From ff789cbd09adc3f193f940d9515dbc3fd1c9c234 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:18:13 +0200 Subject: [PATCH 07/16] Add support for listing preinstall configurations (GET /v2/configuration/) --- src/HypernodeClient.php | 3 + src/Service/Configuration.php | 46 ++++++++ tests/unit/Service/ConfigurationTest.php | 132 +++++++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/Service/Configuration.php create mode 100644 tests/unit/Service/ConfigurationTest.php diff --git a/src/HypernodeClient.php b/src/HypernodeClient.php index f4ac703..fb5ffa6 100644 --- a/src/HypernodeClient.php +++ b/src/HypernodeClient.php @@ -11,6 +11,7 @@ use Hypernode\Api\Service\App; use Hypernode\Api\Service\Backup; use Hypernode\Api\Service\BrancherApp; +use Hypernode\Api\Service\Configuration; use Hypernode\Api\Service\Logbook; use Hypernode\Api\Service\Settings; use Psr\Http\Message\ResponseInterface; @@ -22,6 +23,7 @@ class HypernodeClient public App $app; public Backup $backup; public BrancherApp $brancherApp; + public Configuration $configuration; public Settings $settings; public Logbook $logbook; @@ -32,6 +34,7 @@ public function __construct(HttpMethodsClientInterface $apiClient) $this->app = new App($this); $this->backup = new Backup($this); $this->brancherApp = new BrancherApp($this); + $this->configuration = new Configuration($this); $this->settings = new Settings($this); $this->logbook = new Logbook($this); } diff --git a/src/Service/Configuration.php b/src/Service/Configuration.php new file mode 100644 index 0000000..157f0c0 --- /dev/null +++ b/src/Service/Configuration.php @@ -0,0 +1,46 @@ +client->api->get($requestUrl); + + $this->client->maybeThrowApiExceptions($response); + $data = $this->client->getJsonFromResponse($response); + + foreach ($data['results'] as $item) { + $configurations[] = $item; + } + + $requestUrl = $data['next']; + } + + return $configurations; + } +} diff --git a/tests/unit/Service/ConfigurationTest.php b/tests/unit/Service/ConfigurationTest.php new file mode 100644 index 0000000..e8f5b5d --- /dev/null +++ b/tests/unit/Service/ConfigurationTest.php @@ -0,0 +1,132 @@ + 'Magento 2', + 'configuration_id' => 'magento_2', + 'php_version' => '8.1', + 'mysql_version' => '8.0', + 'varnish_enabled' => false, + ], + [ + 'name' => 'Shopware 6', + 'configuration_id' => 'shopware_6', + 'php_version' => '8.2', + 'mysql_version' => '8.0', + 'varnish_enabled' => true, + ], + ]; + $this->responses->append( + new Response(200, [], json_encode([ + 'count' => 2, + 'next' => null, + 'previous' => null, + 'results' => $configurations, + ])), + ); + + $result = $this->client->configuration->getList(); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/configuration/', $request->getUri()); + $this->assertEquals($configurations, $result); + } + + public function testGetListAcceptsParams() + { + $this->responses->append( + new Response(200, [], json_encode([ + 'count' => 0, + 'next' => null, + 'previous' => null, + 'results' => [], + ])), + ); + + $result = $this->client->configuration->getList(['allow_preinstall' => 'true']); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/configuration/?allow_preinstall=true', $request->getUri()); + $this->assertEquals([], $result); + } + + public function testGetListPaginates() + { + $this->responses->append( + new Response(200, [], json_encode([ + 'count' => 2, + 'next' => 'https://api.hypernode.com/v2/configuration/?limit=1&offset=1', + 'previous' => null, + 'results' => [ + [ + 'name' => 'Magento 2', + 'configuration_id' => 'magento_2', + ], + ], + ])), + new Response(200, [], json_encode([ + 'count' => 2, + 'next' => null, + 'previous' => null, + 'results' => [ + [ + 'name' => 'Shopware 6', + 'configuration_id' => 'shopware_6', + ], + ], + ])), + ); + + $result = $this->client->configuration->getList(); + + $this->assertCount(2, $result); + $this->assertEquals('magento_2', $result[0]['configuration_id']); + $this->assertEquals('shopware_6', $result[1]['configuration_id']); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals( + 'https://api.hypernode.com/v2/configuration/?limit=1&offset=1', + $request->getUri() + ); + } + + public function testGetListRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->configuration->getList(); + } + + public function testGetListRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->configuration->getList(); + } +} From 99e8fc2ad070a186a8685a147bb607ec9b78eaf7 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:18:48 +0200 Subject: [PATCH 08/16] Add support for listing Insights annotations (GET /v2/insights-annotation/) --- src/HypernodeClient.php | 3 + src/Service/InsightsAnnotation.php | 48 +++++++ tests/unit/Service/InsightsAnnotationTest.php | 127 ++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 src/Service/InsightsAnnotation.php create mode 100644 tests/unit/Service/InsightsAnnotationTest.php diff --git a/src/HypernodeClient.php b/src/HypernodeClient.php index fb5ffa6..5fb594a 100644 --- a/src/HypernodeClient.php +++ b/src/HypernodeClient.php @@ -12,6 +12,7 @@ use Hypernode\Api\Service\Backup; use Hypernode\Api\Service\BrancherApp; use Hypernode\Api\Service\Configuration; +use Hypernode\Api\Service\InsightsAnnotation; use Hypernode\Api\Service\Logbook; use Hypernode\Api\Service\Settings; use Psr\Http\Message\ResponseInterface; @@ -24,6 +25,7 @@ class HypernodeClient public Backup $backup; public BrancherApp $brancherApp; public Configuration $configuration; + public InsightsAnnotation $insightsAnnotation; public Settings $settings; public Logbook $logbook; @@ -35,6 +37,7 @@ public function __construct(HttpMethodsClientInterface $apiClient) $this->backup = new Backup($this); $this->brancherApp = new BrancherApp($this); $this->configuration = new Configuration($this); + $this->insightsAnnotation = new InsightsAnnotation($this); $this->settings = new Settings($this); $this->logbook = new Logbook($this); } diff --git a/src/Service/InsightsAnnotation.php b/src/Service/InsightsAnnotation.php new file mode 100644 index 0000000..6665ac8 --- /dev/null +++ b/src/Service/InsightsAnnotation.php @@ -0,0 +1,48 @@ +client->api->get($requestUrl); + + $this->client->maybeThrowApiExceptions($response); + $data = $this->client->getJsonFromResponse($response); + + foreach ($data['results'] as $item) { + $annotations[] = $item; + } + + $requestUrl = $data['next']; + } + + return $annotations; + } +} diff --git a/tests/unit/Service/InsightsAnnotationTest.php b/tests/unit/Service/InsightsAnnotationTest.php new file mode 100644 index 0000000..bde43f6 --- /dev/null +++ b/tests/unit/Service/InsightsAnnotationTest.php @@ -0,0 +1,127 @@ + 'Deployed release 1.2.3', + 'x_axis' => 1756384957, + 'app_name' => 'johndoe', + 'metrics_list' => ['memory_usage'], + 'metadata' => [], + ], + ]; + $this->responses->append( + new Response(200, [], json_encode([ + 'count' => 1, + 'next' => null, + 'previous' => null, + 'results' => $annotations, + ])), + ); + + $result = $this->client->insightsAnnotation->getList(); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/insights-annotation/', $request->getUri()); + $this->assertEquals($annotations, $result); + } + + public function testGetListAcceptsParams() + { + $this->responses->append( + new Response(200, [], json_encode([ + 'count' => 0, + 'next' => null, + 'previous' => null, + 'results' => [], + ])), + ); + + $result = $this->client->insightsAnnotation->getList(['limit' => 10, 'offset' => 20]); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/insights-annotation/?limit=10&offset=20', $request->getUri()); + $this->assertEquals([], $result); + } + + public function testGetListPaginates() + { + $this->responses->append( + new Response(200, [], json_encode([ + 'count' => 2, + 'next' => 'https://api.hypernode.com/v2/insights-annotation/?limit=1&offset=1', + 'previous' => null, + 'results' => [ + [ + 'name' => 'Deployed release 1.2.3', + 'x_axis' => 1756384957, + 'app_name' => 'johndoe', + ], + ], + ])), + new Response(200, [], json_encode([ + 'count' => 2, + 'next' => null, + 'previous' => null, + 'results' => [ + [ + 'name' => 'Deployed release 1.2.4', + 'x_axis' => 1756395957, + 'app_name' => 'johndoe', + ], + ], + ])), + ); + + $result = $this->client->insightsAnnotation->getList(); + + $this->assertCount(2, $result); + $this->assertEquals('Deployed release 1.2.3', $result[0]['name']); + $this->assertEquals('Deployed release 1.2.4', $result[1]['name']); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals( + 'https://api.hypernode.com/v2/insights-annotation/?limit=1&offset=1', + $request->getUri() + ); + } + + public function testGetListRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->insightsAnnotation->getList(); + } + + public function testGetListRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->insightsAnnotation->getList(); + } +} From 5f297e443d397bf77554a3421d67deece87ff7fe Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:19:14 +0200 Subject: [PATCH 09/16] Add support for creating Insights annotations (POST /v2/insights-annotation/create/) --- src/Service/InsightsAnnotation.php | 27 ++++++++++ tests/unit/Service/InsightsAnnotationTest.php | 51 +++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/Service/InsightsAnnotation.php b/src/Service/InsightsAnnotation.php index 6665ac8..939c489 100644 --- a/src/Service/InsightsAnnotation.php +++ b/src/Service/InsightsAnnotation.php @@ -10,6 +10,7 @@ class InsightsAnnotation extends AbstractService { public const V2_INSIGHTS_ANNOTATION_LIST_URL = "/v2/insights-annotation/"; + public const V2_INSIGHTS_ANNOTATION_CREATE_URL = "/v2/insights-annotation/create/"; /** * List all custom Insights annotations for the Hypernodes you have access @@ -45,4 +46,30 @@ public function getList(array $params = []): array return $annotations; } + + /** + * Create a custom Insights annotation for a Hypernode. The annotation is + * shown in the Insights graphs at the point in time given by `x_axis` (a + * unix timestamp in seconds). Optionally restrict the annotation to + * specific graphs with `metrics`, a comma-separated list of metric names; + * when omitted, the annotation applies to all metrics. + * + * @param array $data Annotation data, requires at least the keys + * `name`, `x_axis` and `app`. + * @return array The created annotation + * @throws HypernodeApiClientException + * @throws HypernodeApiServerException + */ + public function create(array $data): array + { + $response = $this->client->api->post( + self::V2_INSIGHTS_ANNOTATION_CREATE_URL, + [], + json_encode($data) + ); + + $this->client->maybeThrowApiExceptions($response); + + return $this->client->getJsonFromResponse($response); + } } diff --git a/tests/unit/Service/InsightsAnnotationTest.php b/tests/unit/Service/InsightsAnnotationTest.php index bde43f6..13471d0 100644 --- a/tests/unit/Service/InsightsAnnotationTest.php +++ b/tests/unit/Service/InsightsAnnotationTest.php @@ -124,4 +124,55 @@ public function testGetListRaisesServerExceptions() $this->client->insightsAnnotation->getList(); } + + public function testCreate() + { + $annotationData = [ + 'name' => 'Deployed release 1.2.3', + 'x_axis' => 1756384957, + 'app' => 'johndoe', + 'metrics' => 'memory_usage', + ]; + $createdAnnotation = array_merge($annotationData, [ + 'id' => 123, + 'created' => '2026-07-15T12:00:00Z', + 'modified' => '2026-07-15T12:00:00Z', + ]); + $this->responses->append( + new Response(201, [], json_encode($createdAnnotation)), + ); + + $result = $this->client->insightsAnnotation->create($annotationData); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals('/v2/insights-annotation/create/', $request->getUri()); + $this->assertJson((string)$request->getBody()); + $this->assertEquals($annotationData, json_decode((string)$request->getBody(), true)); + $this->assertEquals($createdAnnotation, $result); + } + + public function testCreateRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->insightsAnnotation->create(['name' => 'Deployed release 1.2.3']); + } + + public function testCreateRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->insightsAnnotation->create(['name' => 'Deployed release 1.2.3']); + } } From 251739cd9f8b5bc2f10af07629f5f4c548f77d52 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:19:43 +0200 Subject: [PATCH 10/16] Add support for listing products (GET /v2/product/) --- src/HypernodeClient.php | 3 ++ src/Service/Product.php | 29 ++++++++++++ tests/unit/Service/ProductTest.php | 73 ++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 src/Service/Product.php create mode 100644 tests/unit/Service/ProductTest.php diff --git a/src/HypernodeClient.php b/src/HypernodeClient.php index 5fb594a..503d627 100644 --- a/src/HypernodeClient.php +++ b/src/HypernodeClient.php @@ -14,6 +14,7 @@ use Hypernode\Api\Service\Configuration; use Hypernode\Api\Service\InsightsAnnotation; use Hypernode\Api\Service\Logbook; +use Hypernode\Api\Service\Product; use Hypernode\Api\Service\Settings; use Psr\Http\Message\ResponseInterface; @@ -26,6 +27,7 @@ class HypernodeClient public BrancherApp $brancherApp; public Configuration $configuration; public InsightsAnnotation $insightsAnnotation; + public Product $product; public Settings $settings; public Logbook $logbook; @@ -38,6 +40,7 @@ public function __construct(HttpMethodsClientInterface $apiClient) $this->brancherApp = new BrancherApp($this); $this->configuration = new Configuration($this); $this->insightsAnnotation = new InsightsAnnotation($this); + $this->product = new Product($this); $this->settings = new Settings($this); $this->logbook = new Logbook($this); } diff --git a/src/Service/Product.php b/src/Service/Product.php new file mode 100644 index 0000000..812fa9e --- /dev/null +++ b/src/Service/Product.php @@ -0,0 +1,29 @@ +client->api->get(self::V2_PRODUCT_LIST_URL); + + $this->client->maybeThrowApiExceptions($response); + + return $this->client->getJsonFromResponse($response); + } +} diff --git a/tests/unit/Service/ProductTest.php b/tests/unit/Service/ProductTest.php new file mode 100644 index 0000000..fa8685e --- /dev/null +++ b/tests/unit/Service/ProductTest.php @@ -0,0 +1,73 @@ + 'FALCON_XS_202203', + 'name' => 'Falcon XS', + 'backups_enabled' => true, + 'is_development' => false, + 'varnish_supported' => false, + 'supports_sla' => true, + 'price' => 1234, + 'currency' => 'EUR', + ], + [ + 'code' => 'FALCON_M_202203', + 'name' => 'Falcon M', + 'backups_enabled' => true, + 'is_development' => false, + 'varnish_supported' => true, + 'supports_sla' => true, + 'price' => 2345, + 'currency' => 'EUR', + ], + ]; + $this->responses->append( + new Response(200, [], json_encode($products)), + ); + + $result = $this->client->product->getList(); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/product/', $request->getUri()); + $this->assertEquals($products, $result); + } + + public function testGetListRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->product->getList(); + } + + public function testGetListRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->product->getList(); + } +} From 4f4408dff7c19272ffc62c518e8fbfc7dde948f1 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:20:09 +0200 Subject: [PATCH 11/16] Add support for retrieving the current product (GET /v2/product/app/{app}/current/) --- src/Service/Product.php | 20 ++++++++++++ tests/unit/Service/ProductTest.php | 52 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/Service/Product.php b/src/Service/Product.php index 812fa9e..0f67089 100644 --- a/src/Service/Product.php +++ b/src/Service/Product.php @@ -10,6 +10,7 @@ class Product extends AbstractService { public const V2_PRODUCT_LIST_URL = "/v2/product/"; + public const V2_PRODUCT_APP_CURRENT_URL = "/v2/product/app/%s/current/"; /** * Show all Hypernode products. @@ -26,4 +27,23 @@ public function getList(): array return $this->client->getJsonFromResponse($response); } + + /** + * Show the current product for the Hypernode. + * + * @param string $app Name of the Hypernode + * @return array The current product + * @throws HypernodeApiClientException + * @throws HypernodeApiServerException + */ + public function getCurrent(string $app): array + { + $url = sprintf(self::V2_PRODUCT_APP_CURRENT_URL, $app); + + $response = $this->client->api->get($url); + + $this->client->maybeThrowApiExceptions($response); + + return $this->client->getJsonFromResponse($response); + } } diff --git a/tests/unit/Service/ProductTest.php b/tests/unit/Service/ProductTest.php index fa8685e..3129dd4 100644 --- a/tests/unit/Service/ProductTest.php +++ b/tests/unit/Service/ProductTest.php @@ -70,4 +70,56 @@ public function testGetListRaisesServerExceptions() $this->client->product->getList(); } + + public function testGetCurrent() + { + $product = [ + 'code' => 'FALCON_S_202203', + 'name' => 'Falcon S', + 'backups_enabled' => true, + 'is_development' => false, + 'varnish_supported' => true, + 'supports_sla' => true, + 'price' => 1234, + 'currency' => 'EUR', + 'flavor' => [ + 'name' => '2CPU/8GB/60GB (Falcon S 202202)', + 'redis_size' => '1024', + ], + ]; + $this->responses->append( + new Response(200, [], json_encode($product)), + ); + + $result = $this->client->product->getCurrent('johndoe'); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/product/app/johndoe/current/', $request->getUri()); + $this->assertEquals($product, $result); + } + + public function testGetCurrentRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->product->getCurrent('johndoe'); + } + + public function testGetCurrentRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->product->getCurrent('johndoe'); + } } From 19a360e574deacc20474d53ed1ce9932bad4a0d4 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:20:40 +0200 Subject: [PATCH 12/16] Add support for listing whitelist entries (GET /v2/whitelist/{app}/) --- src/HypernodeClient.php | 3 ++ src/Service/Whitelist.php | 36 +++++++++++++ tests/unit/Service/WhitelistTest.php | 75 ++++++++++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 src/Service/Whitelist.php create mode 100644 tests/unit/Service/WhitelistTest.php diff --git a/src/HypernodeClient.php b/src/HypernodeClient.php index 503d627..5f85b86 100644 --- a/src/HypernodeClient.php +++ b/src/HypernodeClient.php @@ -16,6 +16,7 @@ use Hypernode\Api\Service\Logbook; use Hypernode\Api\Service\Product; use Hypernode\Api\Service\Settings; +use Hypernode\Api\Service\Whitelist; use Psr\Http\Message\ResponseInterface; class HypernodeClient @@ -30,6 +31,7 @@ class HypernodeClient public Product $product; public Settings $settings; public Logbook $logbook; + public Whitelist $whitelist; public function __construct(HttpMethodsClientInterface $apiClient) { @@ -43,6 +45,7 @@ public function __construct(HttpMethodsClientInterface $apiClient) $this->product = new Product($this); $this->settings = new Settings($this); $this->logbook = new Logbook($this); + $this->whitelist = new Whitelist($this); } public function getJsonFromResponse(ResponseInterface $response) diff --git a/src/Service/Whitelist.php b/src/Service/Whitelist.php new file mode 100644 index 0000000..f057666 --- /dev/null +++ b/src/Service/Whitelist.php @@ -0,0 +1,36 @@ + 'waf'] + * @return array List of whitelist entries + * @throws HypernodeApiClientException + * @throws HypernodeApiServerException + */ + public function getList(string $app, array $params = []): array + { + $url = sprintf(self::V2_WHITELIST_URL, $app); + if ($params) { + $url .= '?' . http_build_query($params); + } + + $response = $this->client->api->get($url); + + $this->client->maybeThrowApiExceptions($response); + + return $this->client->getJsonFromResponse($response); + } +} diff --git a/tests/unit/Service/WhitelistTest.php b/tests/unit/Service/WhitelistTest.php new file mode 100644 index 0000000..5cb1aee --- /dev/null +++ b/tests/unit/Service/WhitelistTest.php @@ -0,0 +1,75 @@ + 1234, + 'created' => '2026-07-14T14:49:06Z', + 'type' => 'waf', + 'description' => 'Office IP', + 'ip' => '1.2.3.4', + 'app' => 'johndoe', + ], + ]; + $this->responses->append( + new Response(200, [], json_encode($entries)), + ); + + $result = $this->client->whitelist->getList('johndoe'); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/whitelist/johndoe/', $request->getUri()); + $this->assertEquals($entries, $result); + } + + public function testGetListAcceptsParams() + { + $this->responses->append( + new Response(200, [], json_encode([])), + ); + + $result = $this->client->whitelist->getList('johndoe', ['type' => 'waf']); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('GET', $request->getMethod()); + $this->assertEquals('/v2/whitelist/johndoe/?type=waf', $request->getUri()); + $this->assertEquals([], $result); + } + + public function testGetListRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->whitelist->getList('johndoe'); + } + + public function testGetListRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->whitelist->getList('johndoe'); + } +} From 65cd64f38b01c5b4ad611ed357c3562ebf7e319d Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:21:04 +0200 Subject: [PATCH 13/16] Add support for creating whitelist entries (POST /v2/whitelist/{app}/) --- src/Service/Whitelist.php | 22 ++++++++++++ tests/unit/Service/WhitelistTest.php | 50 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/Service/Whitelist.php b/src/Service/Whitelist.php index f057666..50f55ae 100644 --- a/src/Service/Whitelist.php +++ b/src/Service/Whitelist.php @@ -33,4 +33,26 @@ public function getList(string $app, array $params = []): array return $this->client->getJsonFromResponse($response); } + + /** + * Add a whitelist entry for the Hypernode. + * + * @param string $app Name of the Hypernode + * @param array $data Whitelist entry data, requires at least the key `ip`. + * Optional keys are `type` (database, ftp, phpmyadmin, ssh or waf, + * defaults to database) and `description`. + * @return array The created whitelist entry + * @throws HypernodeApiClientException + * @throws HypernodeApiServerException + */ + public function create(string $app, array $data): array + { + $url = sprintf(self::V2_WHITELIST_URL, $app); + + $response = $this->client->api->post($url, [], json_encode($data)); + + $this->client->maybeThrowApiExceptions($response); + + return $this->client->getJsonFromResponse($response); + } } diff --git a/tests/unit/Service/WhitelistTest.php b/tests/unit/Service/WhitelistTest.php index 5cb1aee..5cb4715 100644 --- a/tests/unit/Service/WhitelistTest.php +++ b/tests/unit/Service/WhitelistTest.php @@ -72,4 +72,54 @@ public function testGetListRaisesServerExceptions() $this->client->whitelist->getList('johndoe'); } + + public function testCreate() + { + $entryData = [ + 'ip' => '1.2.3.4', + 'type' => 'waf', + 'description' => 'Office IP', + ]; + $createdEntry = array_merge($entryData, [ + 'id' => 1234, + 'created' => '2026-07-15T12:00:00Z', + 'app' => 'johndoe', + ]); + $this->responses->append( + new Response(200, [], json_encode($createdEntry)), + ); + + $result = $this->client->whitelist->create('johndoe', $entryData); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('POST', $request->getMethod()); + $this->assertEquals('/v2/whitelist/johndoe/', $request->getUri()); + $this->assertJson((string)$request->getBody()); + $this->assertEquals($entryData, json_decode((string)$request->getBody(), true)); + $this->assertEquals($createdEntry, $result); + } + + public function testCreateRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->whitelist->create('johndoe', ['ip' => '1.2.3.4']); + } + + public function testCreateRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->whitelist->create('johndoe', ['ip' => '1.2.3.4']); + } } From 07dd43ad5775001c8f8fe17a6098cab2a5fb15a3 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:21:31 +0200 Subject: [PATCH 14/16] Add support for deleting whitelist entries (DELETE /v2/whitelist/{app}/) --- src/Service/Whitelist.php | 19 +++++++++++++ tests/unit/Service/WhitelistTest.php | 40 ++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/src/Service/Whitelist.php b/src/Service/Whitelist.php index 50f55ae..37ebbb7 100644 --- a/src/Service/Whitelist.php +++ b/src/Service/Whitelist.php @@ -55,4 +55,23 @@ public function create(string $app, array $data): array return $this->client->getJsonFromResponse($response); } + + /** + * Remove a whitelist entry for the Hypernode. + * + * @param string $app Name of the Hypernode + * @param array $data Data identifying the entry to remove, e.g. + * ['ip' => '1.2.3.4', 'type' => 'waf'] + * @return void + * @throws HypernodeApiClientException + * @throws HypernodeApiServerException + */ + public function delete(string $app, array $data): void + { + $url = sprintf(self::V2_WHITELIST_URL, $app); + + $response = $this->client->api->delete($url, [], json_encode($data)); + + $this->client->maybeThrowApiExceptions($response); + } } diff --git a/tests/unit/Service/WhitelistTest.php b/tests/unit/Service/WhitelistTest.php index 5cb4715..9f5efe9 100644 --- a/tests/unit/Service/WhitelistTest.php +++ b/tests/unit/Service/WhitelistTest.php @@ -122,4 +122,44 @@ public function testCreateRaisesServerExceptions() $this->client->whitelist->create('johndoe', ['ip' => '1.2.3.4']); } + + public function testDelete() + { + $this->responses->append( + new Response(204, [], null), + ); + + $entryData = ['ip' => '1.2.3.4', 'type' => 'waf']; + $this->client->whitelist->delete('johndoe', $entryData); + + $request = $this->responses->getLastRequest(); + $this->assertEquals('DELETE', $request->getMethod()); + $this->assertEquals('/v2/whitelist/johndoe/', $request->getUri()); + $this->assertJson((string)$request->getBody()); + $this->assertEquals($entryData, json_decode((string)$request->getBody(), true)); + } + + public function testDeleteRaisesClientExceptions() + { + $badRequestResponse = new Response(400, [], json_encode([ + 'non_field_errors' => ['Your request was invalid.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiClientException($badRequestResponse)); + + $this->client->whitelist->delete('johndoe', ['ip' => '1.2.3.4']); + } + + public function testDeleteRaisesServerExceptions() + { + $badRequestResponse = new Response(500, [], json_encode([ + 'non_field_errors' => ['Something went wrong processing your request.'] + ])); + $this->responses->append($badRequestResponse); + + $this->expectExceptionObject(new HypernodeApiServerException($badRequestResponse)); + + $this->client->whitelist->delete('johndoe', ['ip' => '1.2.3.4']); + } } From ec90a3d3879c798e6d0ae6ed21e878f23841512d Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:22:04 +0200 Subject: [PATCH 15/16] ci: Test against supported PHP versions 8.1 - 8.5 --- .github/workflows/test.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index fdec017..7f8ee76 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -9,7 +9,7 @@ jobs: code_quality: strategy: matrix: - php_version: [8.1, 8.2, 8.3, 8.4] + php_version: [8.1, 8.2, 8.3, 8.4, 8.5] runs-on: ubuntu-latest steps: - name: Checkout hypernode-deploy From 777621d6639f1d22a71d19d9fe7eecafbb6c9840 Mon Sep 17 00:00:00 2001 From: Jonathan Visser Date: Wed, 15 Jul 2026 12:23:22 +0200 Subject: [PATCH 16/16] docs: Document newly supported API features in README --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index a08477d..b31a326 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,14 @@ Here's a list of Hypernode API features implemented in the client. - Updating one or multiple Hypernode settings at once. - Querying/polling the logbook for the status of a job. - Listing, creating, updating and cancelling Brancher Hypernode instances. +- Ordering a new Hypernode. +- Listing and creating backups. +- Listing, creating and deleting whitelist entries. +- Listing products and retrieving the current product of a Hypernode. +- Listing SLA addons. +- Listing available preinstall configurations. +- Listing available settings (EAV descriptions) and block attack descriptions. +- Listing and creating Insights annotations. ## Related projects