diff --git a/src/HetznerAPIClient.php b/src/HetznerAPIClient.php index e44e814..fc004fa 100644 --- a/src/HetznerAPIClient.php +++ b/src/HetznerAPIClient.php @@ -21,6 +21,7 @@ use LKDev\HetznerCloud\Models\Servers\Types\ServerTypes; use LKDev\HetznerCloud\Models\SSHKeys\SSHKeys; use LKDev\HetznerCloud\Models\Volumes\Volumes; +use LKDev\HetznerCloud\Models\Zones\Zones; use Psr\Http\Message\ResponseInterface; /** @@ -327,6 +328,11 @@ public function loadBalancerTypes() return new LoadBalancerTypes($this->httpClient); } + public function zones() + { + return new Zones($this->httpClient); + } + /** * @return GuzzleClient */ diff --git a/src/Models/Zones/AuthoritativeNameservers.php b/src/Models/Zones/AuthoritativeNameservers.php new file mode 100644 index 0000000..bb46fd1 --- /dev/null +++ b/src/Models/Zones/AuthoritativeNameservers.php @@ -0,0 +1,30 @@ +assigned = $assigned; + $this->delegated = $delegated; + $this->delegation_last_check = $delegation_last_check; + $this->delegation_status = $delegation_status; + } + + public static function fromResponse(array $response): AuthoritativeNameservers + { + return new self($response['assigned'], $response['delegated'], $response['delegation_last_check'], $response['delegation_status']); + } +} diff --git a/src/Models/Zones/PrimaryNameserver.php b/src/Models/Zones/PrimaryNameserver.php new file mode 100644 index 0000000..ba25289 --- /dev/null +++ b/src/Models/Zones/PrimaryNameserver.php @@ -0,0 +1,24 @@ +port = $port; + $this->address = $address; + } + + public static function fromResponse(array $response): PrimaryNameserver + { + return new self($response['address'], $response['port']); + } +} diff --git a/src/Models/Zones/RRSet.php b/src/Models/Zones/RRSet.php new file mode 100644 index 0000000..a4ccd46 --- /dev/null +++ b/src/Models/Zones/RRSet.php @@ -0,0 +1,253 @@ +setAdditionalData((object) [ + 'name' => $name, + 'type' => $type, + 'ttl' => $ttl, + 'records' => $records, + 'labels' => (object) $labels, + 'protection' => (object) [], + 'zone' => 0, + ]); + } + + /** + * @param string $id + * @param GuzzleClient|null $client + */ + public function __construct(string $id, ?GuzzleClient $client = null) + { + $this->id = $id; + + parent::__construct($client); + } + + /** + * @param $data + * @return \LKDev\HetznerCloud\Models\Zones\RRSet + */ + public function setAdditionalData($data) + { + $this->name = $data->name; + $this->type = $data->type; + $this->ttl = $data->ttl; + $this->records = $data->records; + $this->labels = get_object_vars($data->labels); + $this->protection = RRSetProtection::parse($data->protection); + $this->zone = $data->zone; + + return $this; + } + + public static function parse($input): RRSet + { + return (new self($input->id))->setAdditionalData($input); + } + + public function __toRequest(): array + { + $r = [ + 'name' => $this->name, + 'type' => $this->type, + 'ttl' => $this->ttl, + 'records' => $this->records, + ]; + if (! empty($this->labels)) { + $r['labels'] = $this->labels; + } + + return $r; + } + + /** + * @return RRSet|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function reload() + { + return (new Zone($this->zone))->getRRSetById($this->id); + } + + /** + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function delete(): ?APIResponse + { + $response = $this->httpClient->delete('zones/'.$this->zone.'/rrsets/'.$this->id); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param array $data + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function update(array $data): ?APIResponse + { + $response = $this->httpClient->put('zones/'.$this->zone.'/rrsets/'.$this->id, [ + 'json' => $data, + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'rrset' => self::parse(json_decode((string) $response->getBody())->rrset), + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param bool $change + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function changeProtection(bool $change): ?APIResponse + { + $response = $this->httpClient->post('zones/'.$this->zone.'/rrsets/'.$this->id.'/actions/change_protection', [ + 'json' => [ + 'change' => $change, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param int $ttl + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function changeTTL(int $ttl): ?APIResponse + { + $response = $this->httpClient->post('zones/'.$this->zone.'/rrsets/'.$this->id.'/actions/change_ttl', [ + 'json' => [ + 'ttl' => $ttl, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param array $records + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function setRecords(array $records): ?APIResponse + { + $response = $this->httpClient->post('zones/'.$this->zone.'/rrsets/'.$this->id.'/actions/set_records', [ + 'json' => [ + 'records' => $records, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param array $records + * @param int|null $ttl + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function addRecords(array $records, ?int $ttl = null): ?APIResponse + { + $response = $this->httpClient->post('zones/'.$this->zone.'/rrsets/'.$this->id.'/actions/add_records', [ + 'json' => [ + 'records' => $records, + 'ttl' => $ttl, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param array $records + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function removeRecords(array $records) + { + $response = $this->httpClient->post('zones/'.$this->zone.'/rrsets/'.$this->id.'/actions/remove_records', [ + 'json' => [ + 'records' => $records, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } +} diff --git a/src/Models/Zones/RRSetProtection.php b/src/Models/Zones/RRSetProtection.php new file mode 100644 index 0000000..33049e2 --- /dev/null +++ b/src/Models/Zones/RRSetProtection.php @@ -0,0 +1,49 @@ +change = $delete; + // Force getting the default http client + parent::__construct(null); + } + + /** + * @param array $input + * @return ?RRSetProtection + */ + public static function parse($input) + { + if ($input == null) { + return null; + } + if (! is_array($input)) { + $input = get_object_vars($input); + } + + return new self($input['change'] ?? false); + } +} diff --git a/src/Models/Zones/RRSetRequestOpts.php b/src/Models/Zones/RRSetRequestOpts.php new file mode 100644 index 0000000..794d172 --- /dev/null +++ b/src/Models/Zones/RRSetRequestOpts.php @@ -0,0 +1,37 @@ +name = $name; + $this->type = $type; + parent::__construct($perPage, $page, $labelSelector); + } +} diff --git a/src/Models/Zones/Record.php b/src/Models/Zones/Record.php new file mode 100644 index 0000000..767a6b1 --- /dev/null +++ b/src/Models/Zones/Record.php @@ -0,0 +1,15 @@ +value = $value; + $this->comment = $comment; + } +} diff --git a/src/Models/Zones/Zone.php b/src/Models/Zones/Zone.php new file mode 100644 index 0000000..e00d899 --- /dev/null +++ b/src/Models/Zones/Zone.php @@ -0,0 +1,392 @@ + + */ + public array $primary_nameservers; + + /** + * @var array|\LKDev\HetznerCloud\Models\Protection + */ + public Protection|array $protection; + + /** + * @var array + */ + public array $labels; + + /** + * @var int + */ + public int $ttl; + + /** + * @var int + */ + public int $record_count; + + /** + * @var string + */ + public string $registrar; + + /** + * @var AuthoritativeNameservers + */ + public AuthoritativeNameservers $authoritative_nameservers; + + /** + * @param int $zoneId + * @param GuzzleClient|null $httpClient + */ + public function __construct(int $zoneId, ?GuzzleClient $httpClient = null) + { + $this->id = $zoneId; + parent::__construct($httpClient); + } + + /** + * @param $data + * @return \LKDev\HetznerCloud\Models\Zones\Zone + */ + public function setAdditionalData($data) + { + $this->name = $data->name; + $this->status = $data->status ?: null; + $this->mode = $data->mode ?: null; + $this->created = $data->created; + $this->protection = $data->protection ? Protection::parse($data->protection) : new Protection(false); + $this->labels = get_object_vars($data->labels); + $this->record_count = $data->record_count; + $this->ttl = $data->ttl; + $this->registrar = $data->registrar; + $this->authoritative_nameservers = AuthoritativeNameservers::fromResponse(get_object_vars($data->authoritative_nameservers)); + if (property_exists($data, 'primary_nameservers')) { + $this->primary_nameservers = []; + foreach ($data->primary_nameservers as $primary_nameserver) { + $this->primary_nameservers[] = PrimaryNameserver::fromResponse(get_object_vars($primary_nameserver)); + } + } + + return $this; + } + + /** + * Reload the data of the zone. + * + * @return Zone + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function reload() + { + return HetznerAPIClient::$instance->zones()->get($this->id); + } + + /** + * Deletes a zone. This immediately removes the zone from your account, and it is no longer accessible. + * + * @see https://docs.hetzner.cloud/reference/cloud#zones-delete-a-zone + * + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function delete(): ?APIResponse + { + $response = $this->httpClient->delete($this->replaceZoneIdInUri('zones/{id}')); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + /** + * Update a zone with new meta data. + * + * @see https://docs.hetzner.cloud/reference/cloud#zones-update-a-zone + * + * @param array $data + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function update(array $data) + { + $response = $this->httpClient->put($this->replaceZoneIdInUri('zones/{id}'), [ + 'json' => $data, + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'zone' => self::parse(json_decode((string) $response->getBody())->zone), + ], $response->getHeaders()); + } + + return null; + } + + /** + * Changes the protection configuration of the zone. + * + * @see https://docs.hetzner.cloud/#zone-actions-change-zone-protection + * + * @param bool $delete + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function changeProtection(bool $delete = true): ?APIResponse + { + $response = $this->httpClient->post('zones/'.$this->id.'/actions/change_protection', [ + 'json' => [ + 'delete' => $delete, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + public function exportZonefile(): ?APIResponse + { + $response = $this->httpClient->get('zones/'.$this->id.'/zonefile'); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'zonefile' => json_decode((string) $response->getBody())->zonefile, + ], $response->getHeaders()); + } + + return null; + } + + public function changeTTL(int $ttl): ?APIResponse + { + $response = $this->httpClient->post('zones/'.$this->id.'/actions/change_ttl', [ + 'json' => [ + 'ttl' => $ttl, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + public function importZonefile(string $zonefile): ?APIResponse + { + $response = $this->httpClient->post('zones/'.$this->id.'/actions/import_zonefile', [ + 'json' => [ + 'zonefile' => $zonefile, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param string $uri + * @return string + */ + protected function replaceZoneIdInUri(string $uri): string + { + return str_replace('{id}', $this->id, $uri); + } + + /** + * @param $input + * @return \LKDev\HetznerCloud\Models\Zones\Zone|static|null + */ + public static function parse($input) + { + if ($input == null) { + return null; + } + + return (new self($input->id))->setAdditionalData($input); + } + + /** + * @param array $primary_nameservers + * @return APIResponse|null + * + * @throws APIException + */ + public function changePrimaryNameservers(array $primary_nameservers) + { + $response = $this->httpClient->post('zones/'.$this->id.'/actions/change_primary_nameservers', [ + 'json' => [ + 'primary_nameservers' => $primary_nameservers, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param RRSetRequestOpts|null $requestOpts + * @return array + * + * @throws APIException + */ + public function allRRSets(?RRSetRequestOpts $requestOpts = null): array + { + if ($requestOpts == null) { + $requestOpts = new RRSetRequestOpts(); + } + $entities = []; + $requestOpts->per_page = HetznerAPIClient::MAX_ENTITIES_PER_PAGE; + $max_pages = PHP_INT_MAX; + for ($i = 1; $i < $max_pages; $i++) { + $requestOpts->page = $i; + $_f = $this->listRRSets($requestOpts); + $entities = array_merge($entities, $_f->rrsets); + if ($_f->meta->pagination->page === $_f->meta->pagination->last_page || $_f->meta->pagination->last_page === null) { + $max_pages = 0; + } + } + + return $entities; + } + + /** + * @param RRSetRequestOpts|null $requestOpts + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function listRRSets(?RRSetRequestOpts $requestOpts = null): ?APIResponse + { + if ($requestOpts == null) { + $requestOpts = new RRSetRequestOpts(); + } + $response = $this->httpClient->get('zones/'.$this->id.'/rrsets'.$requestOpts->buildQuery()); + if (! HetznerAPIClient::hasError($response)) { + $resp = json_decode((string) $response->getBody()); + $rrsets = []; + foreach ($resp->rrsets as $rrset) { + $rrsets[] = RRSet::parse($rrset); + } + + return APIResponse::create([ + 'meta' => Meta::parse($resp->meta), + 'rrsets' => $rrsets, + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param string $name + * @param string $type + * @param array $records + * @param int|null $ttl + * @param array|null $labels + * @return APIResponse|null + * + * @throws APIException + */ + public function createRRSet(string $name, string $type, array $records, ?int $ttl = null, ?array $labels = []) + { + $parameters = [ + 'name' => $name, + 'type' => $type, + 'records' => $records, + ]; + if ($ttl !== null) { + $parameters['ttl'] = $ttl; + } + if (! empty($labels)) { + $parameters['labels'] = $labels; + } + + $response = $this->httpClient->post('zones/'.$this->id.'/rrsets', [ + 'json' => $parameters, + ]); + + if (! HetznerAPIClient::hasError($response)) { + $payload = json_decode((string) $response->getBody()); + + return APIResponse::create([ + 'action' => Action::parse($payload->action), + 'rrset' => RRSet::parse($payload->rrset), + ], $response->getHeaders()); + } + + return null; + } + + /** + * @param string $id + * @return RRSet|null + * + * @throws APIException + */ + public function getRRSetById(string $id): ?RRSet + { + $response = $this->httpClient->get('zones/'.$this->id.'/rrsets/'.$id); + if (! HetznerAPIClient::hasError($response)) { + return RRSet::parse(json_decode((string) $response->getBody())->rrset); + } + + return null; + } +} diff --git a/src/Models/Zones/ZoneMode.php b/src/Models/Zones/ZoneMode.php new file mode 100644 index 0000000..c5e2b3f --- /dev/null +++ b/src/Models/Zones/ZoneMode.php @@ -0,0 +1,9 @@ +name = $name; + $this->mode = $mode; + parent::__construct($perPage, $page, $labelSelector); + } +} diff --git a/src/Models/Zones/Zones.php b/src/Models/Zones/Zones.php new file mode 100644 index 0000000..eb224d3 --- /dev/null +++ b/src/Models/Zones/Zones.php @@ -0,0 +1,220 @@ + $primary_nameservers + * @param array $rrsets + * @param string|null $zonefile + * @return APIResponse|null + * + * @throws APIException + */ + public function create(string $name, string $mode, ?int $ttl = null, ?array $labels = [], ?array $primary_nameservers = [], ?array $rrsets = [], ?string $zonefile = '') + { + $parameters = [ + 'name' => $name, + 'mode' => $mode, + ]; + if ($ttl !== null) { + $parameters['ttl'] = $ttl; + } + if (! empty($labels)) { + $parameters['labels'] = $labels; + } + if (! empty($rrsets)) { + $parameters['rrsets'] = []; + foreach ($rrsets as $rrset) { + $parameters['rrsets'][] = $rrset->__toRequest(); + } + } + if (! empty($primary_nameservers)) { + $parameters['primary_nameservers'] = $primary_nameservers; + } + + if (! empty($zonefile)) { + $parameters['zonefile'] = $zonefile; + } + $response = $this->httpClient->post('zones', [ + 'json' => $parameters, + ]); + + if (! HetznerAPIClient::hasError($response)) { + $payload = json_decode((string) $response->getBody()); + + return APIResponse::create([ + 'action' => Action::parse($payload->action), + 'zone' => Zone::parse($payload->zone), + ], $response->getHeaders()); + } + + return null; + } + + /** + * Returns all existing zone objects. + * + * @see https://docs.hetzner.cloud/#resources-zones-get + * + * @param RequestOpts|null $requestOpts + * @return array + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function all(?RequestOpts $requestOpts = null): array + { + if ($requestOpts == null) { + $requestOpts = new ZoneRequestOpts(); + } + + return $this->_all($requestOpts); + } + + /** + * List zone objects. + * + * @see https://docs.hetzner.cloud/#resources-zones-get + * + * @param RequestOpts|null $requestOpts + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function list(?RequestOpts $requestOpts = null): ?APIResponse + { + if ($requestOpts == null) { + $requestOpts = new ZoneRequestOpts(); + } + $response = $this->httpClient->get('zones'.$requestOpts->buildQuery()); + if (! HetznerAPIClient::hasError($response)) { + $resp = json_decode((string) $response->getBody()); + + return APIResponse::create([ + 'meta' => Meta::parse($resp->meta), + $this->_getKeys()['many'] => self::parse($resp->{$this->_getKeys()['many']})->{$this->_getKeys()['many']}, + ], $response->getHeaders()); + } + + return null; + } + + /** + * Returns a specific zone object by its name. The zone must exist inside the project. + * + * @see https://docs.hetzner.cloud/#resources-zones-get + * + * @param string $zoneName + * @return \LKDev\HetznerCloud\Models\Zones\Zone|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function getByName(string $zoneName): ?Zone + { + $response = $this->httpClient->get('zones/'.$zoneName); + if (! HetznerAPIClient::hasError($response)) { + return Zone::parse(json_decode((string) $response->getBody())->{$this->_getKeys()['one']}); + } + + return null; + } + + /** + * Returns a specific zone object by its id. The zone must exist inside the project. + * + * @see https://docs.hetzner.cloud/#resources-zones-get + * + * @param int $zoneId + * @return \LKDev\HetznerCloud\Models\Zones\Zone|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function getById(int $zoneId): ?Zone + { + $response = $this->httpClient->get('zones/'.$zoneId); + if (! HetznerAPIClient::hasError($response)) { + return Zone::parse(json_decode((string) $response->getBody())->{$this->_getKeys()['one']}); + } + + return null; + } + + /** + * Deletes a specific zone object by its id. The zone must exist inside the project. + * + * @see https://docs.hetzner.cloud/#zones-delete-a-zone + * + * @param int $zoneId + * @return \LKDev\HetznerCloud\Models\Actions\Action|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function deleteById(int $zoneId): ?Action + { + $response = $this->httpClient->delete('zones/'.$zoneId); + if (! HetznerAPIClient::hasError($response)) { + $payload = json_decode((string) $response->getBody()); + + return Action::parse($payload->action); + } + + return null; + } + + /** + * @param $input + * @return $this + */ + public function setAdditionalData($input) + { + $this->zones = collect($input) + ->map(function ($zone) { + if ($zone != null) { + return Zone::parse($zone); + } + + return null; + }) + ->toArray(); + + return $this; + } + + /** + * @param $input + * @return static + */ + public static function parse($input) + { + return (new self())->setAdditionalData($input); + } + + /** + * @return array + */ + public function _getKeys(): array + { + return ['one' => 'zone', 'many' => 'zones']; + } +} diff --git a/tests/Unit/Models/Zones/RRSetTest.php b/tests/Unit/Models/Zones/RRSetTest.php new file mode 100644 index 0000000..5e9f40e --- /dev/null +++ b/tests/Unit/Models/Zones/RRSetTest.php @@ -0,0 +1,124 @@ +mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_rrset.json'))); + $this->rrset = (new Zone(4711))->getRRSetById('www/A'); + } + + public function testDelete() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('delete_rrset'))); + $resp = $this->rrset->delete(); + + $this->assertEquals('delete_rrset', $resp->action->command); + $this->assertEquals($this->rrset->zone, $resp->action->resources[0]->id); + $this->assertEquals('zone', $resp->action->resources[0]->type); + $this->assertLastRequestEquals('DELETE', '/zones/4711/rrsets/www/A'); + } + + public function testUpdate() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_rrset.json'))); + $this->rrset->update(['labels' => ['environment' => 'prod']]); + $this->assertLastRequestEquals('PUT', '/zones/4711/rrsets/www/A'); + $this->assertLastRequestBodyParametersEqual(['labels' => ['environment' => 'prod']]); + } + + public function testChangeProtection() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('change_rrset_protection'))); + $apiResponse = $this->rrset->changeProtection(true); + $this->assertEquals('change_rrset_protection', $apiResponse->action->command); + $this->assertEquals($this->rrset->zone, $apiResponse->action->resources[0]->id); + $this->assertEquals('zone', $apiResponse->action->resources[0]->type); + $this->assertLastRequestEquals('POST', '/zones/4711/rrsets/www/A/actions/change_protection'); + $this->assertLastRequestBodyParametersEqual(['change' => true]); + } + + public function testChangeTTL() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('change_rrset_ttl'))); + $apiResponse = $this->rrset->changeTTL(50); + $this->assertEquals('change_rrset_ttl', $apiResponse->action->command); + $this->assertEquals($this->rrset->zone, $apiResponse->action->resources[0]->id); + $this->assertEquals('zone', $apiResponse->action->resources[0]->type); + $this->assertLastRequestEquals('POST', '/zones/4711/rrsets/www/A/actions/change_ttl'); + $this->assertLastRequestBodyParametersEqual(['ttl' => 50]); + } + + public function testSetRecords() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('set_rrset_records'))); + $apiResponse = $this->rrset->setRecords([ + new Record('198.51.100.1', 'my webserver at Hetzner Cloud'), + ]); + $this->assertEquals('set_rrset_records', $apiResponse->action->command); + $this->assertEquals($this->rrset->zone, $apiResponse->action->resources[0]->id); + $this->assertEquals('zone', $apiResponse->action->resources[0]->type); + $this->assertLastRequestEquals('POST', '/zones/4711/rrsets/www/A/actions/set_records'); + $this->assertLastRequestBodyParametersEqual(['records' => [ + [ + 'value' => '198.51.100.1', + 'comment' => 'my webserver at Hetzner Cloud', + ], + ]]); + } + + public function testAddRecords() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('add_rrset_records'))); + $apiResponse = $this->rrset->addRecords([ + new Record('198.51.100.1', 'my webserver at Hetzner Cloud'), + ], 3600); + $this->assertEquals('add_rrset_records', $apiResponse->action->command); + $this->assertEquals($this->rrset->zone, $apiResponse->action->resources[0]->id); + $this->assertEquals('zone', $apiResponse->action->resources[0]->type); + $this->assertLastRequestEquals('POST', '/zones/4711/rrsets/www/A/actions/add_records'); + $this->assertLastRequestBodyParametersEqual(['ttl' => 3600, 'records' => [ + [ + 'value' => '198.51.100.1', + 'comment' => 'my webserver at Hetzner Cloud', + ], + ]]); + } + + public function testRemoveRecords() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('remove_rrset_records'))); + $apiResponse = $this->rrset->removeRecords([ + new Record('198.51.100.1', 'my webserver at Hetzner Cloud'), + ]); + $this->assertEquals('remove_rrset_records', $apiResponse->action->command); + $this->assertEquals($this->rrset->zone, $apiResponse->action->resources[0]->id); + $this->assertEquals('zone', $apiResponse->action->resources[0]->type); + $this->assertLastRequestEquals('POST', '/zones/4711/rrsets/www/A/actions/remove_records'); + $this->assertLastRequestBodyParametersEqual(['records' => [ + [ + 'value' => '198.51.100.1', + 'comment' => 'my webserver at Hetzner Cloud', + ], + ]]); + } + + protected function getGenericActionResponse(string $command) + { + return str_replace('$command', $command, file_get_contents(__DIR__.'/fixtures/zone_action_generic.json')); + } +} diff --git a/tests/Unit/Models/Zones/ZoneTest.php b/tests/Unit/Models/Zones/ZoneTest.php new file mode 100644 index 0000000..9fab56f --- /dev/null +++ b/tests/Unit/Models/Zones/ZoneTest.php @@ -0,0 +1,155 @@ +hetznerApi->getHttpClient()); + + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone.json'))); + $this->zone = $tmp->getById(4711); + } + + public function testDelete() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('delete_zone'))); + $resp = $this->zone->delete(); + + $this->assertEquals('delete_zone', $resp->action->command); + $this->assertEquals($this->zone->id, $resp->action->resources[0]->id); + $this->assertEquals('zone', $resp->action->resources[0]->type); + $this->assertLastRequestEquals('DELETE', '/zones/4711'); + } + + public function testUpdate() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone.json'))); + $this->zone->update(['name' => 'new-name']); + $this->assertLastRequestEquals('PUT', '/zones/4711'); + $this->assertLastRequestBodyParametersEqual(['name' => 'new-name']); + } + + public function testChangeProtection() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_action_change_protection.json'))); + $apiResponse = $this->zone->changeProtection(true); + $this->assertEquals('change_protection', $apiResponse->action->command); + $this->assertEquals($this->zone->id, $apiResponse->action->resources[0]->id); + $this->assertEquals('zone', $apiResponse->action->resources[0]->type); + $this->assertLastRequestEquals('POST', '/zones/4711/actions/change_protection'); + $this->assertLastRequestBodyParametersEqual(['delete' => true]); + } + + public function testChangeTTL() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('change_ttl'))); + $apiResponse = $this->zone->changeTTL(50); + $this->assertEquals('change_ttl', $apiResponse->action->command); + $this->assertEquals($this->zone->id, $apiResponse->action->resources[0]->id); + $this->assertEquals('zone', $apiResponse->action->resources[0]->type); + $this->assertLastRequestEquals('POST', '/zones/4711/actions/change_ttl'); + $this->assertLastRequestBodyParametersEqual(['ttl' => 50]); + } + + public function testImportZonefile() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('import_zonefile'))); + $apiResponse = $this->zone->importZonefile('zonefile_content'); + $this->assertEquals('import_zonefile', $apiResponse->action->command); + $this->assertEquals($this->zone->id, $apiResponse->action->resources[0]->id); + $this->assertEquals('zone', $apiResponse->action->resources[0]->type); + $this->assertLastRequestEquals('POST', '/zones/4711/actions/import_zonefile'); + $this->assertLastRequestBodyParametersEqual(['zonefile' => 'zonefile_content']); + } + + public function testTestChangePrimaryNameservers() + { + $this->mockHandler->append(new Response(200, [], $this->getGenericActionResponse('import_zonefile'))); + $apiResponse = $this->zone->changePrimaryNameservers([ + new PrimaryNameserver('192.168.178.1', 53), + ]); + $this->assertEquals('import_zonefile', $apiResponse->action->command); + $this->assertEquals($this->zone->id, $apiResponse->action->resources[0]->id); + $this->assertEquals('zone', $apiResponse->action->resources[0]->type); + $this->assertLastRequestEquals('POST', '/zones/4711/actions/change_primary_nameservers'); + $this->assertLastRequestBodyParametersEqual(['primary_nameservers' => [['address' => '192.168.178.1', 'port' => 53]]]); + } + + public function testExportZonefile() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_zonefile.json'))); + $apiResponse = $this->zone->exportZonefile(); + $this->assertNotEmpty($apiResponse->zonefile); + $this->assertLastRequestEquals('GET', '/zones/4711/zonefile'); + } + + public function testAllRRSets() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_rrsets.json'))); + $rrsets = $this->zone->allRRSets(); + $this->assertCount(1, $rrsets); + $rrset = $rrsets[0]; + $this->assertEquals($rrset->id, 'www/A'); + $this->assertEquals($rrset->name, 'www'); + + $this->assertLastRequestEquals('GET', '/zones/'.$this->zone->id.'/rrsets'); + } + + public function testListRRSets() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_rrsets.json'))); + $rrsets = $this->zone->listRRSets()->rrsets; + $this->assertCount(1, $rrsets); + $rrset = $rrsets[0]; + $this->assertEquals($rrset->id, 'www/A'); + $this->assertEquals($rrset->name, 'www'); + + $this->assertLastRequestEquals('GET', '/zones/'.$this->zone->id.'/rrsets'); + } + + public function testCreateRRSet() + { + $this->mockHandler->append(new Response(201, [], file_get_contents(__DIR__.'/fixtures/zone_create_rrset.json'))); + $apiResponse = $this->zone->createRRSet('www', 'A', [new Record('198.51.100.1', 'my webserver at Hetzner Cloud')], 3600, ['environment' => 'prod']); + $this->assertNotEmpty($apiResponse->rrset); + $this->assertNotEmpty($apiResponse->action); + + $this->assertLastRequestEquals('POST', '/zones/4711/rrsets'); + $this->assertLastRequestBodyParametersEqual([ + 'name' => 'www', + 'type' => 'A', + 'ttl' => 3600, + 'labels' => ['environment' => 'prod'], + 'records' => [['value' => '198.51.100.1', 'comment' => 'my webserver at Hetzner Cloud']]]); + } + + public function testGetById() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_rrset.json'))); + $rrset = $this->zone->getRRSetById('www/A'); + $this->assertEquals($rrset->id, 'www/A'); + $this->assertEquals($rrset->name, 'www'); + + $this->assertLastRequestEquals('GET', '/zones/4711/rrsets/www/A'); + } + + protected function getGenericActionResponse(string $command) + { + return str_replace('$command', $command, file_get_contents(__DIR__.'/fixtures/zone_action_generic.json')); + } +} diff --git a/tests/Unit/Models/Zones/ZonesTest.php b/tests/Unit/Models/Zones/ZonesTest.php new file mode 100644 index 0000000..e795257 --- /dev/null +++ b/tests/Unit/Models/Zones/ZonesTest.php @@ -0,0 +1,129 @@ +zones = new zones($this->hetznerApi->getHttpClient()); + } + + public function testCreatePrimarySimple() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_create.json'))); + $resp = $this->zones->create('example.com', ZoneMode::PRIMARY); + + $Zone = $resp->getResponsePart('zone'); + $this->assertEquals($Zone->id, 4711); + $this->assertEquals($Zone->name, 'example.com'); + + $this->assertNotNull($resp->action); + + $this->assertLastRequestEquals('POST', '/zones'); + $this->assertLastRequestBodyParametersEqual(['name' => 'example.com', 'mode' => 'primary']); + } + + public function testCreatePrimaryFull() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_create.json'))); + $resp = $this->zones->create('example.com', ZoneMode::PRIMARY, 10, ['key' => 'value'], [], [ + RRSet::create('@', 'A', [ + new Record('192.0.2.1', 'my comment'), + ], 3600, []), + ]); + + $Zone = $resp->getResponsePart('zone'); + $this->assertEquals($Zone->id, 4711); + $this->assertEquals($Zone->name, 'example.com'); + + $this->assertNotNull($resp->action); + + $this->assertLastRequestEquals('POST', '/zones'); + $this->assertLastRequestBodyParametersEqual(['name' => 'example.com', + 'mode' => 'primary', + 'ttl' => 10, + 'labels' => ['key' => 'value'], + 'rrsets' => [['type' => 'A', 'name' => '@', 'ttl' => 3600, 'records' => [['value' => '192.0.2.1', 'comment' => 'my comment']]]]]); + } + + public function testCreateSecondary() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_create.json'))); + $resp = $this->zones->create('example.com', ZoneMode::SECONDARY, 10, ['key' => 'value'], [ + new PrimaryNameserver('192.168.178.1', 53), + ], ); + + $Zone = $resp->getResponsePart('zone'); + $this->assertEquals($Zone->id, 4711); + $this->assertEquals($Zone->name, 'example.com'); + + $this->assertNotNull($resp->action); + + $this->assertLastRequestEquals('POST', '/zones'); + $this->assertLastRequestBodyParametersEqual([ + 'name' => 'example.com', + 'mode' => 'secondary', + 'ttl' => 10, + 'labels' => ['key' => 'value'], + 'primary_nameservers' => [['address' => '192.168.178.1', 'port' => 53]]]); + } + + public function testGetByName() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone.json'))); + $Zone = $this->zones->getByName('example.com'); + $this->assertEquals(4711, $Zone->id); + $this->assertEquals('example.com', $Zone->name); + + $this->assertLastRequestEquals('GET', '/zones/example.com'); + } + + public function testGet() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone.json'))); + $Zone = $this->zones->get(4711); + $this->assertEquals($Zone->id, 4711); + $this->assertEquals($Zone->name, 'example.com'); + + $this->assertLastRequestEquals('GET', '/zones/4711'); + } + + public function testAll() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zones.json'))); + $zones = $this->zones->all(); + $this->assertCount(1, $zones); + $Zone = $zones[0]; + $this->assertEquals($Zone->id, 4711); + $this->assertEquals($Zone->name, 'example.com'); + + $this->assertLastRequestEquals('GET', '/zones'); + } + + public function testList() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zones.json'))); + $zones = $this->zones->list()->zones; + $this->assertCount(1, $zones); + $Zone = $zones[0]; + $this->assertEquals($Zone->id, 4711); + $this->assertEquals($Zone->name, 'example.com'); + + $this->assertLastRequestEquals('GET', '/zones'); + } +} diff --git a/tests/Unit/Models/Zones/fixtures/zone.json b/tests/Unit/Models/Zones/fixtures/zone.json new file mode 100644 index 0000000..9f38eed --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zone.json @@ -0,0 +1,44 @@ +{ + "zone": { + "id": 4711, + "name": "example.com", + "created": "2016-01-30T23:55:00+00:00", + "mode": "primary", + "primary_nameservers": [ + { + "address": "198.51.100.1", + "port": 53 + }, + { + "address": "203.0.113.1", + "port": 53 + } + ], + "labels": { + "environment": "prod", + "example.com/my": "label", + "just-a-key": "" + }, + "protection": { + "delete": false + }, + "ttl": 10800, + "status": "ok", + "record_count": 0, + "authoritative_nameservers": { + "assigned": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de." + ], + "delegated": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de." + ], + "delegation_last_check": "2016-01-30T23:55:00+00:00", + "delegation_status": "valid" + }, + "registrar": "hetzner" + } +} diff --git a/tests/Unit/Models/Zones/fixtures/zone_action_change_protection.json b/tests/Unit/Models/Zones/fixtures/zone_action_change_protection.json new file mode 100644 index 0000000..9207027 --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zone_action_change_protection.json @@ -0,0 +1,17 @@ +{ + "action": { + "id": 1, + "command": "change_protection", + "status": "running", + "progress": 50, + "started": "2016-01-30T23:55:00+00:00", + "finished": null, + "resources": [ + { + "id": 4711, + "type": "zone" + } + ], + "error": null + } +} diff --git a/tests/Unit/Models/Zones/fixtures/zone_action_generic.json b/tests/Unit/Models/Zones/fixtures/zone_action_generic.json new file mode 100644 index 0000000..e070683 --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zone_action_generic.json @@ -0,0 +1,17 @@ +{ + "action": { + "id": 13, + "command": "$command", + "status": "running", + "progress": 0, + "started": "2016-01-30T23:50:00+00:00", + "finished": null, + "resources": [ + { + "id": 4711, + "type": "zone" + } + ], + "error": null + } +} diff --git a/tests/Unit/Models/Zones/fixtures/zone_create.json b/tests/Unit/Models/Zones/fixtures/zone_create.json new file mode 100644 index 0000000..e7c46fb --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zone_create.json @@ -0,0 +1,47 @@ +{ + "zone": { + "id": 4711, + "name": "example.com", + "mode": "primary", + "status": "ok", + "ttl": 3600, + "record_count": 4, + "protection": { + "delete": false + }, + "labels": { + "key": "value" + }, + "created": "2016-01-30T23:50:00+00:00", + "registrar": "hetzner", + "authoritative_nameservers": { + "assigned": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de." + ], + "delegated": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de." + ], + "delegation_last_check": "2016-01-30T23:50:00+00:00", + "delegation_status": "valid" + } + }, + "action": { + "id": 1, + "command": "create_zone", + "status": "running", + "progress": 50, + "started": "2016-01-30T23:50:00+00:00", + "finished": null, + "resources": [ + { + "id": 42, + "type": "zone" + } + ], + "error": null + } +} diff --git a/tests/Unit/Models/Zones/fixtures/zone_create_rrset.json b/tests/Unit/Models/Zones/fixtures/zone_create_rrset.json new file mode 100644 index 0000000..3ddac28 --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zone_create_rrset.json @@ -0,0 +1,36 @@ +{ + "rrset": { + "id": "www/A", + "name": "www", + "type": "A", + "ttl": 3600, + "labels": { + "key": "value" + }, + "protection": { + "change": false + }, + "records": [ + { + "value": "198.51.100.1", + "comment": "My web server at Hetzner Cloud." + } + ], + "zone": 42 + }, + "action": { + "id": 1, + "command": "create_rrset", + "status": "running", + "progress": 50, + "started": "2016-01-30T23:55:00+00:00", + "finished": null, + "resources": [ + { + "id": 42, + "type": "zone" + } + ], + "error": null + } +} diff --git a/tests/Unit/Models/Zones/fixtures/zone_rrset.json b/tests/Unit/Models/Zones/fixtures/zone_rrset.json new file mode 100644 index 0000000..f425563 --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zone_rrset.json @@ -0,0 +1,23 @@ +{ + "rrset": { + "id": "www/A", + "name": "www", + "type": "A", + "ttl": 3600, + "labels": { + "environment": "prod", + "example.com/my": "label", + "just-a-key": "" + }, + "protection": { + "change": false + }, + "records": [ + { + "value": "198.51.100.1", + "comment": "My web server at Hetzner Cloud." + } + ], + "zone": 4711 + } +} diff --git a/tests/Unit/Models/Zones/fixtures/zone_rrsets.json b/tests/Unit/Models/Zones/fixtures/zone_rrsets.json new file mode 100644 index 0000000..c53723f --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zone_rrsets.json @@ -0,0 +1,35 @@ +{ + "rrsets": [ + { + "id": "www/A", + "name": "www", + "type": "A", + "ttl": 3600, + "labels": { + "environment": "prod", + "example.com/my": "label", + "just-a-key": "" + }, + "protection": { + "change": false + }, + "records": [ + { + "value": "198.51.100.1", + "comment": "My web server at Hetzner Cloud." + } + ], + "zone": 4711 + } + ], + "meta": { + "pagination": { + "page": 1, + "per_page": 25, + "previous_page": null, + "next_page": null, + "last_page": 1, + "total_entries": 1 + } + } +} diff --git a/tests/Unit/Models/Zones/fixtures/zone_zonefile.json b/tests/Unit/Models/Zones/fixtures/zone_zonefile.json new file mode 100644 index 0000000..7ce3906 --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zone_zonefile.json @@ -0,0 +1,3 @@ +{ + "zonefile": "$ORIGIN\texample.com.\n$TTL\t3600\n\n@\tIN\tSOA\thydrogen.ns.hetzner.com. dns.hetzner.com. 2024010100 86400 10800 3600000 3600\n\n@\tIN\t10800\tNS\thydrogen.ns.hetzner.com. ; Some comment.\n@\tIN\t10800\tNS\toxygen.ns.hetzner.com.\n@\tIN\t10800\tNS\thelium.ns.hetzner.de.\n" +} diff --git a/tests/Unit/Models/Zones/fixtures/zones.json b/tests/Unit/Models/Zones/fixtures/zones.json new file mode 100644 index 0000000..3700d59 --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zones.json @@ -0,0 +1,56 @@ +{ + "zones": [ + { + "id": 4711, + "name": "example.com", + "created": "2016-01-30T23:55:00+00:00", + "mode": "primary", + "primary_nameservers": [ + { + "address": "198.51.100.1", + "port": 53 + }, + { + "address": "203.0.113.1", + "port": 53 + } + ], + "labels": { + "environment": "prod", + "example.com/my": "label", + "just-a-key": "" + }, + "protection": { + "delete": false + }, + "ttl": 10800, + "status": "ok", + "record_count": 0, + "authoritative_nameservers": { + "assigned": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de." + ], + "delegated": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de." + ], + "delegation_last_check": "2016-01-30T23:55:00+00:00", + "delegation_status": "valid" + }, + "registrar": "hetzner" + } + ], + "meta": { + "pagination": { + "page": 1, + "per_page": 25, + "previous_page": null, + "next_page": null, + "last_page": 1, + "total_entries": 1 + } + } +}