From 18a85d37ba75a0267c3aefe49c468cd0bea54797 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:35:22 +0100 Subject: [PATCH] fix(hetzner): hide server types that are out of stock or deprecated in a location Co-Authored-By: Claude Opus 5.5 --- .../Controllers/Api/HetznerController.php | 12 +++- app/Services/HetznerService.php | 25 ++++++-- openapi.json | 11 +++- openapi.yaml | 9 ++- tests/Feature/Api/HetznerApiTest.php | 59 +++++++++++++++++-- 5 files changed, 102 insertions(+), 14 deletions(-) diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 509558a00f..e59d85ba87 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -140,7 +140,7 @@ class HetznerController extends Controller #[OA\Get( summary: 'Get Hetzner Server Types', - description: 'Get all available Hetzner server types (instance sizes).', + description: 'Get the Hetzner server types (instance sizes) that can be ordered now. Each type lists only the locations where it is in stock and not deprecated.', path: '/hetzner/server-types', operationId: 'get-hetzner-server-types', security: [ @@ -148,6 +148,13 @@ class HetznerController extends Controller ], tags: ['Hetzner'], parameters: [ + new OA\Parameter( + name: 'location', + in: 'query', + required: false, + description: 'Return only the server types that can be ordered in this location, for example nbg1.', + schema: new OA\Schema(type: 'string') + ), new OA\Parameter( name: 'cloud_provider_token_uuid', in: 'query', @@ -231,6 +238,7 @@ class HetznerController extends Controller $validator = customApiValidator($request->all(), [ 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'location' => 'nullable|string', ]); if ($validator->fails()) { @@ -253,7 +261,7 @@ class HetznerController extends Controller try { $hetznerService = new HetznerService($token->token); - $serverTypes = $hetznerService->getServerTypes(); + $serverTypes = $hetznerService->getServerTypes($request->query('location')); return response()->json($serverTypes); } catch (CloudProviderApiException $e) { diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index faeeee654f..88aaef118e 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -118,16 +118,29 @@ class HetznerService ]); } - public function getServerTypes(): array + /** + * Server types that can be ordered now. Each type keeps only the locations + * where it is in stock and not deprecated, and types with no such location are removed. + */ + public function getServerTypes(?string $location = null): array { $types = $this->requestPaginated('get', '/server_types', 'server_types'); - // Filter out entries where "deprecated" is explicitly true - $filtered = array_filter($types, function ($type) { - return ! (isset($type['deprecated']) && $type['deprecated'] === true); - }); + return collect($types) + ->reject(fn (array $type) => ($type['deprecated'] ?? false) === true) + ->map(function (array $type) { + $type['locations'] = collect($type['locations'] ?? []) + ->filter(fn (array $typeLocation) => ($typeLocation['available'] ?? true) === true && empty($typeLocation['deprecation'])) + ->values() + ->all(); - return array_values($filtered); + return $type; + }) + ->filter(fn (array $type) => $location === null + ? $type['locations'] !== [] + : in_array($location, array_column($type['locations'], 'name'), true)) + ->values() + ->all(); } public function getSshKeys(): array diff --git a/openapi.json b/openapi.json index 6ed44cd78d..25d528b690 100644 --- a/openapi.json +++ b/openapi.json @@ -12588,9 +12588,18 @@ "Hetzner" ], "summary": "Get Hetzner Server Types", - "description": "Get all available Hetzner server types (instance sizes).", + "description": "Get the Hetzner server types (instance sizes) that can be ordered now. Each type lists only the locations where it is in stock and not deprecated.", "operationId": "get-hetzner-server-types", "parameters": [ + { + "name": "location", + "in": "query", + "description": "Return only the server types that can be ordered in this location, for example nbg1.", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "cloud_provider_token_uuid", "in": "query", diff --git a/openapi.yaml b/openapi.yaml index 5e5476fcae..09caf34728 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -8129,9 +8129,16 @@ paths: tags: - Hetzner summary: 'Get Hetzner Server Types' - description: 'Get all available Hetzner server types (instance sizes).' + description: 'Get the Hetzner server types (instance sizes) that can be ordered now. Each type lists only the locations where it is in stock and not deprecated.' operationId: get-hetzner-server-types parameters: + - + name: location + in: query + description: 'Return only the server types that can be ordered in this location, for example nbg1.' + required: false + schema: + type: string - name: cloud_provider_token_uuid in: query diff --git a/tests/Feature/Api/HetznerApiTest.php b/tests/Feature/Api/HetznerApiTest.php index 2916004e71..b792e00c17 100644 --- a/tests/Feature/Api/HetznerApiTest.php +++ b/tests/Feature/Api/HetznerApiTest.php @@ -127,8 +127,8 @@ describe('GET /api/v1/hetzner/server-types', function () { Http::fake([ 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ 'server_types' => [ - ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20], - ['id' => 2, 'name' => 'cx21', 'description' => 'CX21', 'cores' => 2, 'memory' => 4.0, 'disk' => 40], + ['id' => 1, 'name' => 'cx11', 'description' => 'CX11', 'cores' => 1, 'memory' => 2.0, 'disk' => 20, 'locations' => [['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => null]]], + ['id' => 2, 'name' => 'cx21', 'description' => 'CX21', 'cores' => 2, 'memory' => 4.0, 'disk' => 40, 'locations' => [['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => null]]], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), @@ -148,8 +148,8 @@ describe('GET /api/v1/hetzner/server-types', function () { Http::fake([ 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ 'server_types' => [ - ['id' => 1, 'name' => 'cx11', 'deprecated' => false], - ['id' => 2, 'name' => 'cx21', 'deprecated' => true], + ['id' => 1, 'name' => 'cx11', 'deprecated' => false, 'locations' => [['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => null]]], + ['id' => 2, 'name' => 'cx21', 'deprecated' => true, 'locations' => [['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => null]]], ], 'meta' => ['pagination' => ['next_page' => null]], ], 200), @@ -165,6 +165,57 @@ describe('GET /api/v1/hetzner/server-types', function () { $response->assertJsonFragment(['name' => 'cx11']); $response->assertJsonMissing(['name' => 'cx21']); }); + + test('removes locations where a server type is out of stock or deprecated', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'server_types' => [ + ['id' => 1, 'name' => 'cx22', 'locations' => [ + ['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => null], + ['id' => 2, 'name' => 'fsn1', 'available' => false, 'deprecation' => null], + ['id' => 3, 'name' => 'hel1', 'available' => true, 'deprecation' => ['announced' => '2025-09-24T00:00:00+00:00', 'unavailable_after' => '2025-12-31T00:00:00+00:00']], + ]], + ['id' => 2, 'name' => 'cpx11', 'locations' => [ + ['id' => 2, 'name' => 'fsn1', 'available' => false, 'deprecation' => null], + ]], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/hetzner/server-types?cloud_provider_token_uuid='.$this->hetznerToken->uuid); + + $response->assertStatus(200); + expect($response->json())->toHaveCount(1) + ->and($response->json('0.name'))->toBe('cx22') + ->and(collect($response->json('0.locations'))->pluck('name')->all())->toBe(['nbg1']); + }); + + test('returns only server types that can be ordered in the given location', function () { + Http::fake([ + 'https://api.hetzner.cloud/v1/server_types*' => Http::response([ + 'server_types' => [ + ['id' => 1, 'name' => 'cx22', 'locations' => [ + ['id' => 1, 'name' => 'nbg1', 'available' => true, 'deprecation' => null], + ['id' => 2, 'name' => 'fsn1', 'available' => false, 'deprecation' => null], + ]], + ['id' => 2, 'name' => 'cpx11', 'locations' => [ + ['id' => 2, 'name' => 'fsn1', 'available' => true, 'deprecation' => null], + ]], + ], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + ])->getJson('/api/v1/hetzner/server-types?location=fsn1&cloud_provider_token_uuid='.$this->hetznerToken->uuid); + + $response->assertStatus(200); + expect(collect($response->json())->pluck('name')->all())->toBe(['cpx11']); + }); }); describe('GET /api/v1/hetzner/images', function () {