diff --git a/CHANGELOG.md b/CHANGELOG.md index 58aa635..8a4ed5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGE LOG * Add PHP 8.5 support * Fixed the `Droplet::create` SSH keys parameter type documentation * Fixed hydration of object-backed App Platform and project resource fields +* Fixed `LoadBalancer` hydration and update serialization ## 5.0.5 (03/05/2025) diff --git a/src/Entity/LoadBalancer.php b/src/Entity/LoadBalancer.php index 7831663..ac00462 100644 --- a/src/Entity/LoadBalancer.php +++ b/src/Entity/LoadBalancer.php @@ -25,6 +25,14 @@ final class LoadBalancer extends AbstractEntity public string $ip; + public string $ipv6; + + public string $projectId; + + public int $sizeUnit; + + public string $size; + public string $algorithm; public string $status; @@ -48,11 +56,35 @@ final class LoadBalancer extends AbstractEntity public bool $redirectHttpToHttps; + public bool $enableProxyProtocol; + + public bool $enableBackendKeepalive; + /** * @var int<30, 600> */ public int $httpIdleTimeoutSeconds; + public string $vpcUuid; + + public bool $disableLetsEncryptDnsRecords; + + public array $firewall; + + public string $network; + + public string $networkStack; + + public string $type; + + public array $domains; + + public array $glbSettings; + + public array $targetLoadBalancerIds; + + public string $tlsCipherPolicy; + public function build(array $parameters): void { foreach ($parameters as $property => $value) { @@ -60,13 +92,24 @@ public function build(array $parameters): void if ('forwardingRules' === $property) { $this->forwardingRules = \array_map(fn ($v) => new ForwardingRule($v), $value); + continue; } elseif ('healthCheck' === $property) { $this->healthCheck = new HealthCheck($value); + continue; } elseif ('stickySessions' === $property) { $this->stickySessions = new StickySession($value); + continue; } elseif ('region' === $property) { $this->region = new Region($value); - } elseif (\property_exists($this, $property)) { + continue; + } elseif ( + \in_array($property, ['firewall', 'domains', 'glbSettings', 'targetLoadBalancerIds'], true) && + ($value instanceof \stdClass || \is_array($value)) + ) { + $value = self::normalizeArray($value); + } + + if (\property_exists($this, $property)) { $this->$property = $value; } } @@ -74,18 +117,85 @@ public function build(array $parameters): void public function toArray(): array { - return [ - 'name' => $this->name, - 'region' => $this->region->slug, - 'algorithm' => $this->algorithm, - 'forwarding_rules' => \array_map(function ($rule): array { + $payload = parent::toArray(); + + unset($payload['id'], $payload['ip'], $payload['ipv6'], $payload['status'], $payload['created_at']); + + if (isset($payload['region']) && $payload['region'] instanceof Region) { + $payload['region'] = $payload['region']->slug; + } + + if (isset($payload['forwarding_rules']) && \is_array($payload['forwarding_rules'])) { + /** @var ForwardingRule[] $forwardingRules */ + $forwardingRules = $payload['forwarding_rules']; + $payload['forwarding_rules'] = \array_map(function (ForwardingRule $rule): array { return $rule->toArray(); - }, $this->forwardingRules), - 'health_check' => $this->healthCheck->toArray(), - 'sticky_sessions' => $this->stickySessions->toArray(), - 'droplet_ids' => $this->dropletIds, - 'redirect_http_to_https' => $this->redirectHttpToHttps, - 'http_idle_timeout_seconds' => $this->httpIdleTimeoutSeconds, - ]; + }, $forwardingRules); + } + + if (isset($payload['health_check']) && $payload['health_check'] instanceof HealthCheck) { + $payload['health_check'] = $payload['health_check']->toArray(); + } + + if (isset($payload['sticky_sessions']) && $payload['sticky_sessions'] instanceof StickySession) { + $payload['sticky_sessions'] = $payload['sticky_sessions']->toArray(); + } + + if (isset($payload['tag']) && '' !== $payload['tag']) { + unset($payload['droplet_ids']); + } else { + unset($payload['tag']); + } + + $data = []; + + foreach ([ + 'name', + 'region', + 'algorithm', + 'size_unit', + 'size', + 'forwarding_rules', + 'health_check', + 'sticky_sessions', + 'tag', + 'droplet_ids', + 'redirect_http_to_https', + 'enable_proxy_protocol', + 'enable_backend_keepalive', + 'http_idle_timeout_seconds', + 'vpc_uuid', + 'disable_lets_encrypt_dns_records', + 'project_id', + 'firewall', + 'network', + 'network_stack', + 'type', + 'domains', + 'glb_settings', + 'target_load_balancer_ids', + 'tls_cipher_policy', + ] as $property) { + if (\array_key_exists($property, $payload)) { + $data[$property] = $payload[$property]; + } + } + + return $data; + } + + private static function normalizeArray(array|\stdClass $value): array + { + if ($value instanceof \stdClass) { + $value = \get_object_vars($value); + } + + foreach ($value as $key => $subValue) { + if ($subValue instanceof \stdClass || \is_array($subValue)) { + $value[$key] = self::normalizeArray($subValue); + } + } + + return $value; } } diff --git a/src/Entity/StickySession.php b/src/Entity/StickySession.php index 7ba919d..30276c3 100644 --- a/src/Entity/StickySession.php +++ b/src/Entity/StickySession.php @@ -23,5 +23,5 @@ class StickySession extends AbstractEntity public string $cookieName; - public string $cookieTtlSeconds; + public int $cookieTtlSeconds; } diff --git a/tests/Entity/LoadBalancerTest.php b/tests/Entity/LoadBalancerTest.php new file mode 100644 index 0000000..d60bfae --- /dev/null +++ b/tests/Entity/LoadBalancerTest.php @@ -0,0 +1,409 @@ + + * (c) Graham Campbell + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace DigitalOceanV2\Tests\Entity; + +use DigitalOceanV2\Entity\AbstractEntity; +use DigitalOceanV2\Entity\ForwardingRule; +use DigitalOceanV2\Entity\HealthCheck; +use DigitalOceanV2\Entity\LoadBalancer; +use DigitalOceanV2\Entity\Region; +use DigitalOceanV2\Entity\StickySession; +use PHPUnit\Framework\TestCase; + +/** + * @author Graham Campbell + */ +class LoadBalancerTest extends TestCase +{ + public function testConstructor(): void + { + $values = [ + 'id' => '4de7ac8b-495b-4884-9a69-1050c6793cd6', + 'name' => 'example-lb-01', + 'ip' => '104.131.186.241', + 'algorithm' => 'round_robin', + 'status' => 'new', + 'created_at' => '2017-02-01T22:22:58Z', + 'forwarding_rules' => [ + [ + 'entry_protocol' => 'http', + 'entry_port' => 80, + 'target_protocol' => 'http', + 'target_port' => 80, + 'certificate_id' => '', + 'tls_passthrough' => false, + ], + ], + 'health_check' => [ + 'protocol' => 'http', + 'port' => 80, + 'path' => '/', + 'check_interval_seconds' => 10, + 'response_timeout_seconds' => 5, + 'healthy_threshold' => 5, + 'unhealthy_threshold' => 3, + ], + 'sticky_sessions' => [ + 'type' => 'none', + ], + 'region' => [ + 'name' => 'New York 3', + 'slug' => 'nyc3', + 'available' => true, + 'features' => [ + 'private_networking', + ], + 'sizes' => [ + 's-1vcpu-1gb', + ], + ], + 'droplet_ids' => [3164444, 3164445], + 'redirect_http_to_https' => false, + 'http_idle_timeout_seconds' => 60, + 'tag' => '', + ]; + + $entity = new LoadBalancer($values); + + self::assertInstanceOf(AbstractEntity::class, $entity); + self::assertInstanceOf(LoadBalancer::class, $entity); + self::assertSame($values['id'], $entity->id); + self::assertSame($values['name'], $entity->name); + self::assertSame($values['ip'], $entity->ip); + self::assertSame($values['algorithm'], $entity->algorithm); + self::assertSame($values['status'], $entity->status); + self::assertSame($values['created_at'], $entity->createdAt); + self::assertInstanceOf(ForwardingRule::class, $entity->forwardingRules[0]); + self::assertInstanceOf(HealthCheck::class, $entity->healthCheck); + self::assertInstanceOf(StickySession::class, $entity->stickySessions); + self::assertInstanceOf(Region::class, $entity->region); + self::assertSame($values['droplet_ids'], $entity->dropletIds); + self::assertFalse($entity->redirectHttpToHttps); + self::assertSame(60, $entity->httpIdleTimeoutSeconds); + self::assertSame('', $entity->tag); + } + + public function testConstructorAcceptsApiShapedObjects(): void + { + $payload = \json_decode(<<<'JSON' +{ + "id": "4de7ac8b-495b-4884-9a69-1050c6793cd6", + "name": "example-lb-01", + "ip": "104.131.186.241", + "ipv6": "2604:a880:800:14::85f5:c000", + "algorithm": "round_robin", + "status": "new", + "created_at": "2017-02-01T22:22:58Z", + "project_id": "9cc10173-e9ea-4176-9dbc-a4cee4c4ff30", + "size": "lb-small", + "size_unit": 3, + "enable_proxy_protocol": true, + "enable_backend_keepalive": true, + "vpc_uuid": "c33931f2-a26a-4e61-b85c-4e95a2ec431b", + "disable_lets_encrypt_dns_records": false, + "network": "EXTERNAL", + "network_stack": "DUALSTACK", + "type": "REGIONAL", + "tls_cipher_policy": "STRONG", + "firewall": { + "allow": ["ip:1.2.3.4"], + "deny": ["cidr:10.0.0.0/8"] + }, + "forwarding_rules": [ + { + "entry_protocol": "http", + "entry_port": 80, + "target_protocol": "http", + "target_port": 80, + "certificate_id": "", + "tls_passthrough": false + } + ], + "health_check": { + "protocol": "http", + "port": 80, + "path": "/", + "check_interval_seconds": 10, + "response_timeout_seconds": 5, + "healthy_threshold": 5, + "unhealthy_threshold": 3 + }, + "sticky_sessions": { + "type": "cookies", + "cookie_name": "DO-LB", + "cookie_ttl_seconds": 300 + }, + "region": { + "name": "New York 3", + "slug": "nyc3", + "available": true, + "features": ["private_networking"], + "sizes": ["s-1vcpu-1gb"] + }, + "droplet_ids": [3164444], + "redirect_http_to_https": true, + "http_idle_timeout_seconds": 90 +} +JSON, false, 512, \JSON_THROW_ON_ERROR); + + $entity = new LoadBalancer($payload); + + self::assertSame('example-lb-01', $entity->name); + self::assertSame('2604:a880:800:14::85f5:c000', $entity->ipv6); + self::assertSame('9cc10173-e9ea-4176-9dbc-a4cee4c4ff30', $entity->projectId); + self::assertSame('lb-small', $entity->size); + self::assertSame(3, $entity->sizeUnit); + self::assertTrue($entity->enableProxyProtocol); + self::assertTrue($entity->enableBackendKeepalive); + self::assertSame('c33931f2-a26a-4e61-b85c-4e95a2ec431b', $entity->vpcUuid); + self::assertFalse($entity->disableLetsEncryptDnsRecords); + self::assertSame('EXTERNAL', $entity->network); + self::assertSame('DUALSTACK', $entity->networkStack); + self::assertSame('REGIONAL', $entity->type); + self::assertSame('STRONG', $entity->tlsCipherPolicy); + self::assertSame(['allow' => ['ip:1.2.3.4'], 'deny' => ['cidr:10.0.0.0/8']], $entity->firewall); + self::assertSame(300, $entity->stickySessions->cookieTtlSeconds); + } + + public function testToArrayPreservesDocumentedFieldsNeededForUpdate(): void + { + $entity = new LoadBalancer([ + 'name' => 'global-lb-01', + 'type' => 'GLOBAL', + 'network' => 'EXTERNAL', + 'network_stack' => 'DUALSTACK', + 'project_id' => '9cc10173-e9ea-4176-9dbc-a4cee4c4ff30', + 'size_unit' => 3, + 'algorithm' => 'round_robin', + 'forwarding_rules' => [ + [ + 'entry_protocol' => 'http', + 'entry_port' => 80, + 'target_protocol' => 'http', + 'target_port' => 80, + 'certificate_id' => '', + 'tls_passthrough' => false, + ], + ], + 'health_check' => [ + 'protocol' => 'http', + 'port' => 80, + 'path' => '/', + 'check_interval_seconds' => 10, + 'response_timeout_seconds' => 5, + 'healthy_threshold' => 5, + 'unhealthy_threshold' => 3, + ], + 'sticky_sessions' => [ + 'type' => 'none', + ], + 'redirect_http_to_https' => false, + 'http_idle_timeout_seconds' => 60, + 'enable_backend_keepalive' => true, + 'enable_proxy_protocol' => true, + 'disable_lets_encrypt_dns_records' => false, + 'firewall' => [ + 'allow' => ['ip:1.2.3.4'], + 'deny' => ['cidr:10.0.0.0/8'], + ], + 'domains' => [ + [ + 'name' => 'example.com', + 'is_managed' => true, + ], + ], + 'glb_settings' => [ + 'target_protocol' => 'http', + 'target_port' => 80, + ], + 'target_load_balancer_ids' => [ + '7dbf91fe-cbdb-48dc-8290-c3a181554905', + ], + 'tls_cipher_policy' => 'STRONG', + 'vpc_uuid' => 'c33931f2-a26a-4e61-b85c-4e95a2ec431b', + ]); + + self::assertSame([ + 'name' => 'global-lb-01', + 'algorithm' => 'round_robin', + 'size_unit' => 3, + 'forwarding_rules' => [ + [ + 'entry_protocol' => 'http', + 'entry_port' => 80, + 'target_protocol' => 'http', + 'target_port' => 80, + 'certificate_id' => '', + 'tls_passthrough' => false, + ], + ], + 'health_check' => [ + 'protocol' => 'http', + 'port' => 80, + 'path' => '/', + 'check_interval_seconds' => 10, + 'response_timeout_seconds' => 5, + 'healthy_threshold' => 5, + 'unhealthy_threshold' => 3, + ], + 'sticky_sessions' => [ + 'type' => 'none', + ], + 'redirect_http_to_https' => false, + 'enable_proxy_protocol' => true, + 'enable_backend_keepalive' => true, + 'http_idle_timeout_seconds' => 60, + 'vpc_uuid' => 'c33931f2-a26a-4e61-b85c-4e95a2ec431b', + 'disable_lets_encrypt_dns_records' => false, + 'project_id' => '9cc10173-e9ea-4176-9dbc-a4cee4c4ff30', + 'firewall' => [ + 'allow' => ['ip:1.2.3.4'], + 'deny' => ['cidr:10.0.0.0/8'], + ], + 'network' => 'EXTERNAL', + 'network_stack' => 'DUALSTACK', + 'type' => 'GLOBAL', + 'domains' => [ + [ + 'name' => 'example.com', + 'is_managed' => true, + ], + ], + 'glb_settings' => [ + 'target_protocol' => 'http', + 'target_port' => 80, + ], + 'target_load_balancer_ids' => [ + '7dbf91fe-cbdb-48dc-8290-c3a181554905', + ], + 'tls_cipher_policy' => 'STRONG', + ], $entity->toArray()); + } + + public function testToArrayPrefersTagOverDropletIds(): void + { + $entity = new LoadBalancer([ + 'name' => 'tag-lb-01', + 'region' => [ + 'name' => 'New York 3', + 'slug' => 'nyc3', + 'available' => true, + 'features' => [], + 'sizes' => [], + ], + 'algorithm' => 'round_robin', + 'forwarding_rules' => [ + [ + 'entry_protocol' => 'http', + 'entry_port' => 80, + 'target_protocol' => 'http', + 'target_port' => 80, + ], + ], + 'health_check' => [ + 'protocol' => 'http', + 'port' => 80, + 'path' => '/', + 'check_interval_seconds' => 10, + 'response_timeout_seconds' => 5, + 'healthy_threshold' => 5, + 'unhealthy_threshold' => 3, + ], + 'sticky_sessions' => [ + 'type' => 'none', + ], + 'tag' => 'prod:web', + 'droplet_ids' => [3164444], + 'redirect_http_to_https' => false, + 'http_idle_timeout_seconds' => 60, + ]); + + $data = $entity->toArray(); + + self::assertSame('prod:web', $data['tag']); + self::assertArrayNotHasKey('droplet_ids', $data); + } + + public function testToArrayRetainsExistingRegionalBehaviorWhenOptionalFieldsAreUnset(): void + { + $entity = new LoadBalancer([ + 'name' => 'example-lb-01', + 'region' => [ + 'name' => 'New York 3', + 'slug' => 'nyc3', + 'available' => true, + 'features' => [], + 'sizes' => [], + ], + 'algorithm' => 'round_robin', + 'forwarding_rules' => [ + [ + 'entry_protocol' => 'http', + 'entry_port' => 80, + 'target_protocol' => 'http', + 'target_port' => 80, + 'certificate_id' => '', + 'tls_passthrough' => false, + ], + ], + 'health_check' => [ + 'protocol' => 'http', + 'port' => 80, + 'path' => '/', + 'check_interval_seconds' => 10, + 'response_timeout_seconds' => 5, + 'healthy_threshold' => 5, + 'unhealthy_threshold' => 3, + ], + 'sticky_sessions' => [ + 'type' => 'none', + ], + 'droplet_ids' => [3164444, 3164445], + 'redirect_http_to_https' => false, + 'http_idle_timeout_seconds' => 60, + ]); + + self::assertSame([ + 'name' => 'example-lb-01', + 'region' => 'nyc3', + 'algorithm' => 'round_robin', + 'forwarding_rules' => [ + [ + 'entry_protocol' => 'http', + 'entry_port' => 80, + 'target_protocol' => 'http', + 'target_port' => 80, + 'certificate_id' => '', + 'tls_passthrough' => false, + ], + ], + 'health_check' => [ + 'protocol' => 'http', + 'port' => 80, + 'path' => '/', + 'check_interval_seconds' => 10, + 'response_timeout_seconds' => 5, + 'healthy_threshold' => 5, + 'unhealthy_threshold' => 3, + ], + 'sticky_sessions' => [ + 'type' => 'none', + ], + 'droplet_ids' => [3164444, 3164445], + 'redirect_http_to_https' => false, + 'http_idle_timeout_seconds' => 60, + ], $entity->toArray()); + } +}