fix(hetzner): hide server types that are out of stock or deprecated in a location

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-27 17:35:22 +01:00
co-authored by Claude Opus 5.5
parent 1242353511
commit 18a85d37ba
5 changed files with 102 additions and 14 deletions
+10 -2
View File
@@ -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) {
+19 -6
View File
@@ -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
+10 -1
View File
@@ -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",
+8 -1
View File
@@ -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
+55 -4
View File
@@ -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 () {