feat: add DigitalOcean API service - #51
Conversation
WalkthroughAdds four new DigitalOcean service classes (Account, Droplet, Key, and a facade) and a composer dependency; implements API client wiring, token management, resource listing/management, SSH key upload/delete, droplet lifecycle helpers, and a small in-memory cache. Changes
Sequence Diagram(s)sequenceDiagram
participant U as User
participant Facade as DigitalOceanService
participant Account as AccountService
participant Droplet as DropletService
participant Key as KeyService
participant API as DigitalOcean API
U->>Facade: initialize(token)
Facade->>Facade: store token, initializeAPI()
Facade->>API: GET /account (verify)
API-->>Facade: account info
Facade->>Account: setAPI(client)
Facade->>Droplet: setAPI(client)
Facade->>Key: setAPI(client)
U->>Droplet: createDroplet(params)
Droplet->>API: POST /droplets
API-->>Droplet: droplet data
Droplet-->>U: {id, name, status}
U->>Droplet: waitForDropletReady(id)
loop poll every interval
Droplet->>API: GET /droplets/{id}
API-->>Droplet: status
end
Droplet-->>U: active / timeout
U->>Key: uploadKey(path, name)
Key->>API: POST /account/keys
API-->>Key: key created (id)
Key-->>U: id
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes
Possibly related PRs
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (9)
app/Services/DigitalOcean/DigitalOceanKeyService.php (2)
88-105: Use HTTP status code/type for 404 detection and accept ID or fingerprint.Parsing the exception message is brittle. The DO API allows deleting by ID or fingerprint; reflect that with a union type and check status code first.
Apply:
- public function deleteKey(int $keyId): void + public function deleteKey(int|string $keyIdOrFingerprint): void { $client = $this->getAPI(); try { $keyApi = $client->key(); - $keyApi->remove((string) $keyId); + $keyApi->remove($keyIdOrFingerprint); } catch (\Throwable $e) { - // Check if 404 (already deleted) - silently succeed - $message = strtolower($e->getMessage()); - if (str_contains($message, '404') || str_contains($message, 'not found')) { - return; - } + $code = (int) $e->getCode(); + if ($code === 404) { + return; // already gone + } + // Fallback: tolerate common message patterns from underlying client + $message = strtolower((string) $e->getMessage()); + if (str_contains($message, '404') || str_contains($message, 'not found')) { + return; + } // Other errors - throw throw new \RuntimeException("Failed to delete SSH key: {$e->getMessage()}", 0, $e); } }Confirm your DigitalOcean client surfaces HTTP status via Exception::getCode() in this project version.
45-52: Guard against directories and empty key content.Minor hardening for local FS reads.
Apply:
- // Read public key content - $publicKey = $this->fs->readFile($publicKeyPath); + // Read public key content + if ($this->fs->isDirectory($publicKeyPath)) { + throw new \RuntimeException("Path is a directory, expected a .pub file: {$publicKeyPath}"); + } + $publicKey = $this->fs->readFile($publicKeyPath); $publicKey = trim($publicKey); + if ($publicKey == '') { + throw new \RuntimeException("SSH public key file is empty: {$publicKeyPath}"); + }app/Services/DigitalOceanService.php (1)
125-131: Construct Client via container/factory for testability.Avoid
new Client(); inject a factory or use$container->build(Client::class)per guidelines. This simplifies mocking and decouples HTTP stack choice.Example:
- $this->api = new Client(); - $this->api->authenticate($this->apiToken); + $this->api = ($this->clientFactory)(); // \Closure returning Client + $this->api->authenticate($this->apiToken);And in constructor:
- public function __construct( + public function __construct( public readonly DigitalOceanAccountService $account, public readonly DigitalOceanKeyService $key, public readonly DigitalOceanDropletService $droplet, + private readonly \Closure $clientFactory, // fn(): Client ) { }I can supply a tiny
DigitalOceanClientFactoryservice if you prefer a class over a closure. Based on coding guidelines.app/Services/DigitalOcean/DigitalOceanDropletService.php (3)
157-166: Handle Droplet networks shape{v4:[], v6:[]}and property names.The API returns
networksas an object withv4/v6arrays; some clients exposeip_addressvsipAddress. Current loop assumes a flat list andipAddress, which may fail.Refactor defensively:
- // Find public IPv4 network - foreach ($droplet->networks as $network) { - if ($network->type === 'public' && $network->version === 4) { - return $network->ipAddress; - } - } + // Find public IPv4 network (supports {v4:[], v6:[]} or flat arrays) + $items = []; + $networks = $droplet->networks; + if (is_object($networks)) { + $items = array_merge($networks->v4 ?? [], $networks->v6 ?? []); + } elseif (is_array($networks)) { + $items = $networks; + } + foreach ($items as $n) { + $type = $n->type ?? null; + $ver = (int) ($n->version ?? 4); + $ip = $n->ipAddress ?? ($n->ip_address ?? null); + if ($type === 'public' && $ver === 4 && is_string($ip) && $ip !== '') { + return $ip; + } + }DO docs show
networks: { v4: [], v6: [] }. (docs.digitalocean.com)
60-62: Prefernullfor default VPC overfalse.
vpc_uuidis optional; omitting it (ornull) uses the region’s default VPC.private_networkingis deprecated;falsemay be misinterpreted by client versions.Apply:
- // Prepare VPC parameter (API expects string|bool, false for default) - $vpcParam = $vpcUuid ?? false; + // Prepare VPC parameter (API expects string|null; null => default VPC) + $vpcParam = $vpcUuid; // null uses default VPCReference (Terraform docs mirror API semantics). (docs.digitalocean.com)
118-140: Consider adding a max attempts guard and jitter for long waits.The tight loop with
sleep()can block for minutes. Optional: cap attempts and add small jitter for friendlier cancellation.Apply:
- while (true) { + $attempt = 0; + while (true) { $status = $this->getDropletStatus($dropletId); @@ - sleep($pollIntervalSeconds); + $attempt++; + usleep(($pollIntervalSeconds * 1000000) + random_int(0, 200000)); // + up to 200ms jitterapp/Services/DigitalOcean/DigitalOceanAccountService.php (3)
65-86: Large accounts may require paging.If you expect many sizes, regions, or images, consider
ResultPagerto fetch all pages consistently across endpoints.Example (sizes):
$pager = new \DigitalOceanV2\ResultPager($client); $sizes = $pager->fetchAll($client->size(), 'getAll');This approach is shown in the official client examples. (packagist.org)
73-79: Round RAM GB for display.Avoid long decimals from MB→GB conversion.
Apply:
- $memory = $size->memory / 1024; // Convert MB to GB + $memory = number_format($size->memory / 1024, 1); // MB → GB (1 decimal)
138-146: Clarify the 'default' VPC sentinel.
getUserVpcs()returns'default' => 'Use default VPC', while droplet creation treatsnull(or omitted) as default. Ensure the caller maps'default'tonullconsistently, or return an explicitnullsentinel out-of-band.Option: return a separate data structure with a
defaultboolean or document the mapping in the method PHPDoc. Reference for default behavior: Terraform docs mirror API semantics. (docs.digitalocean.com)
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (4)
app/Services/DigitalOcean/DigitalOceanAccountService.php(1 hunks)app/Services/DigitalOcean/DigitalOceanDropletService.php(1 hunks)app/Services/DigitalOcean/DigitalOceanKeyService.php(1 hunks)app/Services/DigitalOceanService.php(1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.php
📄 CodeRabbit inference engine (.cursor/rules/00-main.mdc)
**/*.php: Eliminate single-use methods: inline if a method is called only once
Cache computed values: initialize expensive calculations in the constructor
Avoid method call overhead: prefer direct property access when appropriate
**/*.php: Adhere to PSR-12, enable strict_types, and leverage PHP 8.x features (union types, match, attributes, readonly)
Use explicit return types, including generic-like annotations where applicable (e.g., Collection<int, User>)
Prefer Symfony components (e.g., Filesystem, Process) over native PHP functions for testability
Always import classes via use statements; only use root FQDNs for core exceptions (e.g., \InvalidArgumentException, \RuntimeException). Do not use inline FQDNs for non-root namespaces
Create objects via $container->build(ClassName::class) everywhere except DTOs, value objects, and pure data structures
Use minimalist DocBlocks documenting description, parameters, and return types for classes and functions
Follow the specified comment structure with section headers/subheaders and spacing; remove obsolete comments with removed code
Files:
app/Services/DigitalOcean/DigitalOceanKeyService.phpapp/Services/DigitalOcean/DigitalOceanDropletService.phpapp/Services/DigitalOcean/DigitalOceanAccountService.phpapp/Services/DigitalOceanService.php
**/*Service.php
📄 CodeRabbit inference engine (.cursor/rules/01-architecture.mdc)
**/*Service.php: Services must perform no console I/O and should accept/return plain PHP types
Services are dependency-injected via constructor and encapsulate business logic, external APIs, and file operations
Stateful services should use lazy loading and explicit initialization methods (e.g., load(), initialize()) and document requirements
Files:
app/Services/DigitalOcean/DigitalOceanKeyService.phpapp/Services/DigitalOcean/DigitalOceanDropletService.phpapp/Services/DigitalOcean/DigitalOceanAccountService.phpapp/Services/DigitalOceanService.php
**/*{Command,Service}.php
📄 CodeRabbit inference engine (.cursor/rules/01-architecture.mdc)
All dependencies should be expressed in constructor signatures; avoid circular dependencies
Files:
app/Services/DigitalOcean/DigitalOceanKeyService.phpapp/Services/DigitalOcean/DigitalOceanDropletService.phpapp/Services/DigitalOcean/DigitalOceanAccountService.phpapp/Services/DigitalOceanService.php
🧬 Code graph analysis (4)
app/Services/DigitalOcean/DigitalOceanKeyService.php (4)
app/Services/FilesystemService.php (3)
FilesystemService(27-106)exists(41-44)readFile(51-54)app/Services/DigitalOceanService.php (1)
__construct(26-31)app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
setAPI(24-27)getAPI(181-188)app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
setAPI(22-25)getAPI(203-210)
app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
setAPI(24-27)getAPI(181-188)app/Services/DigitalOcean/DigitalOceanKeyService.php (2)
setAPI(27-30)getAPI(112-119)
app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
setAPI(22-25)getAPI(203-210)app/Services/DigitalOcean/DigitalOceanKeyService.php (2)
setAPI(27-30)getAPI(112-119)
app/Services/DigitalOceanService.php (3)
app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
DigitalOceanAccountService(17-189)setAPI(24-27)app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
DigitalOceanDropletService(15-211)setAPI(22-25)app/Services/DigitalOcean/DigitalOceanKeyService.php (3)
DigitalOceanKeyService(15-120)__construct(19-22)setAPI(27-30)
🔇 Additional comments (1)
app/Services/DigitalOceanService.php (1)
59-64: Auth check endpoint choice is valid.Using
$client->account()->getUserInformation()is a lightweight token validation call. LGTM.This endpoint exists in DigitalOceanPHP/Client examples. (packagist.org)
There was a problem hiding this comment.
Actionable comments posted: 0
🧹 Nitpick comments (1)
app/Services/DigitalOceanService.php (1)
132-136: Improve error message specificity.The generic error message could be more helpful by mentioning the specific methods callers should use.
Apply this diff:
if ($this->token === null || $this->token === '') { throw new \RuntimeException( - 'DigitalOcean API token not set. '. - 'Set API token before making API requests.' + 'DigitalOcean API token not set. Call setToken() or initialize() before making API requests.' ); }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
app/Services/DigitalOceanService.php(1 hunks)
🧰 Additional context used
📓 Path-based instructions (3)
**/*.php
📄 CodeRabbit inference engine (.cursor/rules/00-main.mdc)
**/*.php: Eliminate single-use methods: inline if a method is called only once
Cache computed values: initialize expensive calculations in the constructor
Avoid method call overhead: prefer direct property access when appropriate
**/*.php: Adhere to PSR-12, enable strict_types, and leverage PHP 8.x features (union types, match, attributes, readonly)
Use explicit return types, including generic-like annotations where applicable (e.g., Collection<int, User>)
Prefer Symfony components (e.g., Filesystem, Process) over native PHP functions for testability
Always import classes via use statements; only use root FQDNs for core exceptions (e.g., \InvalidArgumentException, \RuntimeException). Do not use inline FQDNs for non-root namespaces
Create objects via $container->build(ClassName::class) everywhere except DTOs, value objects, and pure data structures
Use minimalist DocBlocks documenting description, parameters, and return types for classes and functions
Follow the specified comment structure with section headers/subheaders and spacing; remove obsolete comments with removed code
Files:
app/Services/DigitalOceanService.php
**/*Service.php
📄 CodeRabbit inference engine (.cursor/rules/01-architecture.mdc)
**/*Service.php: Services must perform no console I/O and should accept/return plain PHP types
Services are dependency-injected via constructor and encapsulate business logic, external APIs, and file operations
Stateful services should use lazy loading and explicit initialization methods (e.g., load(), initialize()) and document requirements
Files:
app/Services/DigitalOceanService.php
**/*{Command,Service}.php
📄 CodeRabbit inference engine (.cursor/rules/01-architecture.mdc)
All dependencies should be expressed in constructor signatures; avoid circular dependencies
Files:
app/Services/DigitalOceanService.php
🧬 Code graph analysis (1)
app/Services/DigitalOceanService.php (3)
app/Services/DigitalOcean/DigitalOceanAccountService.php (2)
DigitalOceanAccountService(17-189)setAPI(24-27)app/Services/DigitalOcean/DigitalOceanDropletService.php (2)
DigitalOceanDropletService(15-211)setAPI(22-25)app/Services/DigitalOcean/DigitalOceanKeyService.php (3)
DigitalOceanKeyService(15-121)__construct(19-22)setAPI(27-30)
🔇 Additional comments (5)
app/Services/DigitalOceanService.php (5)
1-31: LGTM! Clean setup with proper DI and PHP 8.x features.The file header, imports, and constructor follow PSR-12 and leverage readonly properties correctly. The dependency injection pattern for the three sub-services is well-structured.
44-49: Good explicit initialization pattern for stateful service.The public
initialize()method provides the clear entry point required by the coding guidelines for stateful services. The sequence of setting token, wiring the API client, and verifying authentication is well-structured.
56-62: LGTM! Proper token reset behavior.Resetting the API client when the token changes ensures that subsequent API calls will use the new credentials.
69-79: LGTM! Lightweight authentication verification.Using the account endpoint for token validation is appropriate, and the exception chaining preserves the underlying error context.
88-115: LGTM! Simple in-memory cache utilities.The cache methods provide a clean API for managing cached data. While simple, they offer good encapsulation for the internal cache array.
Summary by CodeRabbit