From 07205455e43c3128c3aba38dcfe7d0c7d7869e43 Mon Sep 17 00:00:00 2001 From: shaunluedeke Date: Sat, 29 Aug 2026 23:32:17 +0200 Subject: [PATCH] Simplify code formatting by removing trailing commas, consolidating doc comments, and inline constructors. Add `friendsofphp/php-cs-fixer` for consistent style. --- .github/workflows/code-style.yml | 22 +++ .github/workflows/tests.yml | 32 ++++ .gitignore | 1 + .php-cs-fixer.dist.php | 45 +++++ README.md | 30 +-- composer.json | 6 +- src/DTO/ContainerInfo.php | 23 ++- src/DTO/ContainerSummary.php | 25 ++- src/DTO/ImageInfo.php | 29 ++- src/DTO/ImageSummary.php | 21 +-- src/DTO/NetworkInfo.php | 28 ++- src/DTO/VolumeInfo.php | 24 ++- src/DockerClient.php | 29 +-- src/Exceptions/DockerApiException.php | 23 +-- src/Exceptions/DockerConnectionException.php | 22 +-- src/Exceptions/DockerException.php | 4 +- src/Exceptions/DockerNotFoundException.php | 4 +- src/Http/DockerResponse.php | 20 +- src/Http/DockerTransport.php | 178 +++++------------- src/Http/DockerTransportInterface.php | 57 +----- src/Http/StreamingSink.php | 29 +-- src/Resources/AbstractResource.php | 26 +-- src/Resources/Containers.php | 139 +++----------- src/Resources/Exec.php | 33 +--- src/Resources/Images.php | 133 ++++--------- src/Resources/Networks.php | 28 +-- src/Resources/System.php | 23 +-- src/Resources/Volumes.php | 29 +-- src/Support/NdjsonLineBuffer.php | 14 +- src/Support/StdioDemultiplexer.php | 21 +-- tests/Integration/DockerIntegrationTest.php | 15 +- tests/Support/FakeDockerTransport.php | 53 +----- tests/Support/TestableDockerTransport.php | 3 +- tests/Unit/DTO/ContainerInfoTest.php | 18 +- tests/Unit/DTO/ContainerSummaryTest.php | 12 +- tests/Unit/DTO/ImageInfoTest.php | 4 +- tests/Unit/DTO/ImageSummaryTest.php | 12 +- tests/Unit/DTO/NetworkInfoTest.php | 8 +- tests/Unit/DTO/VolumeInfoTest.php | 9 +- tests/Unit/DockerClientTest.php | 1 - .../Exceptions/DockerApiExceptionTest.php | 17 +- .../DockerConnectionExceptionTest.php | 7 +- tests/Unit/Http/DockerResponseTest.php | 6 +- tests/Unit/Http/DockerTransportTcpTest.php | 26 +-- tests/Unit/Http/DockerTransportTest.php | 18 +- tests/Unit/Resources/ContainersTest.php | 6 +- 46 files changed, 415 insertions(+), 898 deletions(-) create mode 100644 .github/workflows/code-style.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .php-cs-fixer.dist.php diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 0000000..6d0dcd1 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,22 @@ +name: Check code style + +on: [push] + +jobs: + code-style: + runs-on: ubuntu-latest + name: Code style + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.2 + + - name: Install + run: composer install --prefer-dist --no-interaction + + - name: Code style checks for PHP + run: composer cs \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..72cf3fb --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,32 @@ +name: Execute tests + +on: [push] + +jobs: + tests: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [8.2,8.3,8.4,8.5] + + name: Tests + + steps: + - name: Checkout code + uses: actions/checkout@v7 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + + - name: Install + run: composer install --prefer-dist --no-interaction + + - name: Run unit tests + run: composer test + + - name: Run integration tests against Docker + run: composer test:integration \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0f9e713..ddefd5f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ composer.lock .phpunit.result.cache .phpstan.cache/ .idea/ +.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..dd453d4 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,45 @@ +setRiskyAllowed(false) + ->setRules([ + 'array_syntax' => [ 'syntax' => 'short' ], + 'binary_operator_spaces' => true, + 'cast_spaces' => false, + 'combine_consecutive_unsets' => true, + 'concat_space' => [ 'spacing' => 'one' ], + 'linebreak_after_opening_tag' => true, + 'no_blank_lines_after_class_opening' => true, + 'no_blank_lines_after_phpdoc' => true, + 'no_extra_blank_lines' => true, + 'no_trailing_comma_in_singleline_array' => false, + 'no_whitespace_in_blank_line' => true, + 'no_spaces_around_offset' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'no_whitespace_before_comma_in_array' => true, + 'normalize_index_brace' => true, + 'phpdoc_indent' => true, + 'phpdoc_to_comment' => false, + 'phpdoc_trim' => true, + 'single_quote' => true, + 'ternary_operator_spaces' => true, + 'ternary_to_null_coalescing' => true, + 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], + 'no_break_comment' => false, + 'blank_line_before_statement' => false, + 'line_ending' => true, + 'single_blank_line_at_eof' => true, + 'short_scalar_cast' => true, + 'fully_qualified_strict_types' => true, + 'no_superfluous_phpdoc_tags' => true, + 'no_empty_phpdoc' => true + ]) + ->setFinder((new Finder())->in(__DIR__)) +; diff --git a/README.md b/README.md index 7448342..622cdd9 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ $docker->volumes()->remove('my-volume'); ## Typed results `list()` and `inspect()` on Containers, Images, Networks and Volumes return -typed DTOs — not raw JSON — so you get autocompletion and don't have to +typed DTOs - not raw JSON - so you get autocompletion and don't have to remember Docker's field names: ```php @@ -78,7 +78,7 @@ $image = $docker->images()->inspect('nginx:latest'); echo $image->getName(); // "nginx:latest" (first RepoTag), or null if untagged foreach ($docker->containers()->list(['all' => true]) as $summary) { - echo $summary->getName(), ' — ', $summary->status, "\n"; + echo $summary->getName(), ' - ', $summary->status, "\n"; } ``` @@ -92,11 +92,11 @@ foreach ($docker->containers()->list(['all' => true]) as $summary) { | `volumes()->list()` / `inspect()` | `list` / `DTO\VolumeInfo` | Every DTO only models the commonly-needed fields (id, name, state, labels, -...) — call `->raw()` on any of them to get the full, untouched decoded +...) - call `->raw()` on any of them to get the full, untouched decoded array for anything not promoted to a typed property. Every other resource method (`start()`, `stop()`, `remove()`, `create()`, -...) still returns a `Sytxlabs\Dockphp\Http\DockerResponse` — there's no +...) still returns a `Sytxlabs\Dockphp\Http\DockerResponse` - there's no useful entity to hydrate from an action's ack/204 response. Use `->json()` for the decoded body or `->getBody()` for the raw string. @@ -105,7 +105,7 @@ for the decoded body or `->getBody()` for the raw string. `containers()->create()` accepts the full Docker container config as a single array. Query-only parameters (`name`, `platform`) are automatically split out of the array and sent as query string parameters, the rest is -sent as the JSON request body — you never have to think about the split: +sent as the JSON request body - you never have to think about the split: ```php $container = $docker->containers()->create([ @@ -137,7 +137,7 @@ $docker->volumes()->create('my-volume', ['Driver' => 'local']); ## API version handling By default, the API version is **not** fetched eagerly when you construct -`DockerClient` — it is resolved lazily via a single `GET /version` call the +`DockerClient` - it is resolved lazily via a single `GET /version` call the first time a request is made, then cached for the lifetime of the client. You can override it manually to skip that lookup entirely and pin a @@ -159,12 +159,12 @@ $docker = new DockerClient( ## Error handling -- `Sytxlabs\Dockphp\Exceptions\DockerConnectionException` — the socket +- `Sytxlabs\Dockphp\Exceptions\DockerConnectionException` - the socket could not be reached at all (missing socket, connection refused, timeout). -- `Sytxlabs\Dockphp\Exceptions\DockerApiException` — the Engine responded +- `Sytxlabs\Dockphp\Exceptions\DockerApiException` - the Engine responded with a non-2xx HTTP status. Carries `getStatusCode()` and `getDockerMessage()` (Docker's own JSON error message, when present). -- `Sytxlabs\Dockphp\Exceptions\DockerNotFoundException` — a `DockerApiException` +- `Sytxlabs\Dockphp\Exceptions\DockerNotFoundException` - a `DockerApiException` subclass specifically for HTTP 404 (e.g. inspecting a container that doesn't exist). @@ -190,7 +190,7 @@ try { The Docker Engine API, reached through `/var/run/docker.sock`, grants practically full control over the Docker host. **This package never -changes socket permissions itself** (no `chmod`, no ownership changes) — +changes socket permissions itself** (no `chmod`, no ownership changes) - managing who can read/write that socket is entirely up to you and your deployment. Only grant access to the socket to trusted, trusted-equivalent code. @@ -210,7 +210,7 @@ $docker->containers()->logsStream('web', function (string $chunk) { // Live stats $docker->containers()->statsStream('web', function (string $chunk) { - // one or more JSON objects per chunk — see NdjsonLineBuffer below + // one or more JSON objects per chunk - see NdjsonLineBuffer below }); // Pull with progress @@ -231,7 +231,7 @@ $docker->system()->events(function (array $event) { `pullStream()`/`buildStream()`/`events()` already decode newline-delimited JSON for you via `Sytxlabs\Dockphp\Support\NdjsonLineBuffer`. `logsStream()` -and `attachStream()` hand you raw bytes instead — see the demux section +and `attachStream()` hand you raw bytes instead - see the demux section below. ## Demultiplexing container logs @@ -293,7 +293,7 @@ $docker = DockerClient::tcp('docker.example.com', 2376, tls: true, caFile: '/pat ## Registry authentication (pull/push) `pull()`, `pullStream()`, `push()` and `pushStream()` take an optional -`$registryAuth` array — the usual Docker auth config +`$registryAuth` array - the usual Docker auth config (`username`/`password`/`serveraddress`, or `identitytoken`). It's sent as the base64-encoded `X-Registry-Auth` header Docker expects: @@ -322,8 +322,8 @@ composer stan # static analysis (PHPStan) Integration tests automatically skip themselves when `/var/run/docker.sock` is not present (e.g. on Windows, or a machine -without Docker installed) — no configuration required. +without Docker installed) - no configuration required. ## License -MIT — see [LICENSE](LICENSE). +MIT - see [LICENSE](LICENSE). diff --git a/composer.json b/composer.json index 6a1262f..6db0af2 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,8 @@ }, "require-dev": { "phpunit/phpunit": "^11.0", - "phpstan/phpstan": "^1.11" + "phpstan/phpstan": "^1.11", + "friendsofphp/php-cs-fixer": "^3.15" }, "autoload": { "psr-4": { @@ -32,7 +33,8 @@ "scripts": { "test": "phpunit --testsuite Unit", "test:integration": "phpunit --testsuite Integration", - "stan": "phpstan analyse" + "cs": "phpstan analyse", + "csfix": "php-cs-fixer fix" }, "minimum-stability": "stable", "prefer-stable": true diff --git a/src/DTO/ContainerInfo.php b/src/DTO/ContainerInfo.php index 57f8826..9a74e34 100644 --- a/src/DTO/ContainerInfo.php +++ b/src/DTO/ContainerInfo.php @@ -9,7 +9,7 @@ * * @see https://docs.docker.com/engine/api/latest/#tag/Container/operation/ContainerInspect */ -final class ContainerInfo +final readonly class ContainerInfo { /** * @param list $args @@ -18,17 +18,16 @@ final class ContainerInfo * @param array $raw The untouched, fully decoded source array. */ public function __construct( - public readonly string $id, - public readonly string $name, - public readonly string $image, - public readonly string $path, - public readonly array $args, - public readonly array $state, - public readonly array $config, - public readonly string $created, - private readonly array $raw, - ) { - } + public string $id, + public string $name, + public string $image, + public string $path, + public array $args, + public array $state, + public array $config, + public string $created, + private array $raw, + ) {} /** * @param array $data diff --git a/src/DTO/ContainerSummary.php b/src/DTO/ContainerSummary.php index 173e3e6..1cc4d70 100644 --- a/src/DTO/ContainerSummary.php +++ b/src/DTO/ContainerSummary.php @@ -9,7 +9,7 @@ * * @see https://docs.docker.com/engine/api/latest/#tag/Container/operation/ContainerList */ -final class ContainerSummary +final readonly class ContainerSummary { /** * @param list $names Docker's own names, each still prefixed with a leading "/". @@ -17,18 +17,17 @@ final class ContainerSummary * @param array $raw The untouched, fully decoded source array. */ public function __construct( - public readonly string $id, - public readonly array $names, - public readonly string $image, - public readonly string $imageId, - public readonly string $command, - public readonly int $created, - public readonly string $state, - public readonly string $status, - public readonly array $labels, - private readonly array $raw, - ) { - } + public string $id, + public array $names, + public string $image, + public string $imageId, + public string $command, + public int $created, + public string $state, + public string $status, + public array $labels, + private array $raw, + ) {} /** * @param array $data diff --git a/src/DTO/ImageInfo.php b/src/DTO/ImageInfo.php index 371e92f..e370efc 100644 --- a/src/DTO/ImageInfo.php +++ b/src/DTO/ImageInfo.php @@ -9,7 +9,7 @@ * * @see https://docs.docker.com/engine/api/latest/#tag/Image/operation/ImageInspect */ -final class ImageInfo +final readonly class ImageInfo { /** * @param list $repoTags Empty when the image is untagged. @@ -18,20 +18,19 @@ final class ImageInfo * @param array $raw The untouched, fully decoded source array. */ public function __construct( - public readonly string $id, - public readonly array $repoTags, - public readonly array $repoDigests, - public readonly string $parent, - public readonly string $comment, - public readonly string $created, - public readonly string $author, - public readonly string $architecture, - public readonly string $os, - public readonly int $size, - public readonly array $config, - private readonly array $raw, - ) { - } + public string $id, + public array $repoTags, + public array $repoDigests, + public string $parent, + public string $comment, + public string $created, + public string $author, + public string $architecture, + public string $os, + public int $size, + public array $config, + private array $raw, + ) {} /** * @param array $data diff --git a/src/DTO/ImageSummary.php b/src/DTO/ImageSummary.php index 720004f..d613d6e 100644 --- a/src/DTO/ImageSummary.php +++ b/src/DTO/ImageSummary.php @@ -9,7 +9,7 @@ * * @see https://docs.docker.com/engine/api/latest/#tag/Image/operation/ImageList */ -final class ImageSummary +final readonly class ImageSummary { /** * @param list $repoTags Empty when the image is untagged (`:`). @@ -18,16 +18,15 @@ final class ImageSummary * @param array $raw The untouched, fully decoded source array. */ public function __construct( - public readonly string $id, - public readonly string $parentId, - public readonly array $repoTags, - public readonly array $repoDigests, - public readonly int $created, - public readonly int $size, - public readonly array $labels, - private readonly array $raw, - ) { - } + public string $id, + public string $parentId, + public array $repoTags, + public array $repoDigests, + public int $created, + public int $size, + public array $labels, + private array $raw, + ) {} /** * @param array $data diff --git a/src/DTO/NetworkInfo.php b/src/DTO/NetworkInfo.php index 7e19786..5eb61d3 100644 --- a/src/DTO/NetworkInfo.php +++ b/src/DTO/NetworkInfo.php @@ -5,12 +5,11 @@ namespace Sytxlabs\Dockphp\DTO; /** - * A network, from either `networks()->list()` or `networks()->inspect()` - * — the Engine API uses the same shape for both. + * A network, from either `networks()->list()` or `networks()->inspect()` - the Engine API uses the same shape for both. * * @see https://docs.docker.com/engine/api/latest/#tag/Network/operation/NetworkList */ -final class NetworkInfo +final readonly class NetworkInfo { /** * @param array $labels @@ -18,18 +17,17 @@ final class NetworkInfo * @param array $raw The untouched, fully decoded source array. */ public function __construct( - public readonly string $id, - public readonly string $name, - public readonly string $driver, - public readonly string $scope, - public readonly bool $internal, - public readonly bool $attachable, - public readonly string $created, - public readonly array $labels, - public readonly array $options, - private readonly array $raw, - ) { - } + public string $id, + public string $name, + public string $driver, + public string $scope, + public bool $internal, + public bool $attachable, + public string $created, + public array $labels, + public array $options, + private array $raw, + ) {} /** * @param array $data diff --git a/src/DTO/VolumeInfo.php b/src/DTO/VolumeInfo.php index c271130..0b5a94e 100644 --- a/src/DTO/VolumeInfo.php +++ b/src/DTO/VolumeInfo.php @@ -5,12 +5,11 @@ namespace Sytxlabs\Dockphp\DTO; /** - * A volume, from either `volumes()->list()` or `volumes()->inspect()` - * — the Engine API uses the same shape for both. + * A volume, from either `volumes()->list()` or `volumes()->inspect()` - the Engine API uses the same shape for both. * * @see https://docs.docker.com/engine/api/latest/#tag/Volume/operation/VolumeList */ -final class VolumeInfo +final readonly class VolumeInfo { /** * @param array $labels @@ -18,16 +17,15 @@ final class VolumeInfo * @param array $raw The untouched, fully decoded source array. */ public function __construct( - public readonly string $name, - public readonly string $driver, - public readonly string $mountpoint, - public readonly string $createdAt, - public readonly string $scope, - public readonly array $labels, - public readonly array $options, - private readonly array $raw, - ) { - } + public string $name, + public string $driver, + public string $mountpoint, + public string $createdAt, + public string $scope, + public array $labels, + public array $options, + private array $raw, + ) {} /** * @param array $data diff --git a/src/DockerClient.php b/src/DockerClient.php index 87713b6..fb3708b 100644 --- a/src/DockerClient.php +++ b/src/DockerClient.php @@ -32,44 +32,27 @@ final class DockerClient /** * @param string $socketPath Path to the Docker Engine Unix socket. - * @param string|null $apiVersion Manually pin the API version (e.g. "1.43"). - * When null, it is auto-detected from `/version` - * on first use. + * @param string|null $apiVersion Manually pin the API version (e.g. "1.43"). When null, it is auto-detected from `/version` on first use. * @param float $connectTimeout Connection timeout in seconds. * @param float $timeout Total request timeout in seconds. */ - public function __construct( - string $socketPath = '/var/run/docker.sock', - ?string $apiVersion = null, - float $connectTimeout = 5.0, - float $timeout = 30.0, - ) { + public function __construct(string $socketPath = '/var/run/docker.sock', ?string $apiVersion = null, float $connectTimeout = 5.0, float $timeout = 30.0) + { $this->transport = DockerTransport::forSocket($socketPath, $apiVersion, $connectTimeout, $timeout); } /** - * Connects to a Docker Engine exposed over TCP instead of a Unix - * socket (e.g. a remote host, or Docker Desktop's TCP endpoint). + * Connects to a Docker Engine exposed over TCP instead of a Unix socket (e.g. a remote host, or Docker Desktop's TCP endpoint). * * @param string $host Hostname or IP only, without scheme. * @param string|null $caFile Path to a CA bundle used to verify the server certificate. * @param string|null $certFile Path to the client certificate (mutual TLS). * @param string|null $keyFile Path to the client private key (mutual TLS). */ - public static function tcp( - string $host, - int $port = 2375, - bool $tls = false, - ?string $caFile = null, - ?string $certFile = null, - ?string $keyFile = null, - ?string $apiVersion = null, - float $connectTimeout = 5.0, - float $timeout = 30.0, - ): self { + public static function tcp(string $host, int $port = 2375, bool $tls = false, ?string $caFile = null, ?string $certFile = null, ?string $keyFile = null, ?string $apiVersion = null, float $connectTimeout = 5.0, float $timeout = 30.0): self + { $client = new self(); $client->transport = DockerTransport::forTcp($host, $port, $tls, $caFile, $certFile, $keyFile, $apiVersion, $connectTimeout, $timeout); - return $client; } diff --git a/src/Exceptions/DockerApiException.php b/src/Exceptions/DockerApiException.php index f0ba752..2feb5df 100644 --- a/src/Exceptions/DockerApiException.php +++ b/src/Exceptions/DockerApiException.php @@ -5,20 +5,14 @@ namespace Sytxlabs\Dockphp\Exceptions; /** - * Thrown when the Docker Engine responded with a non-2xx HTTP status. - * Carries the HTTP status code and, when present, Docker's own JSON - * error message (the `"message"` field of the error body). + * Thrown when the Docker Engine responded with a non-2xx HTTP status. Carries the HTTP status code and, when present, Docker's own JSON error message (the `"message"` field of the error body). * * @phpstan-consistent-constructor */ class DockerApiException extends DockerException { - public function __construct( - string $message, - private readonly int $statusCode, - private readonly ?string $dockerMessage = null, - private readonly ?string $responseBody = null, - ) { + public function __construct(string $message, private readonly int $statusCode, private readonly ?string $dockerMessage = null, private readonly ?string $responseBody = null) + { parent::__construct($message, $statusCode); } @@ -30,16 +24,7 @@ public static function fromResponse(string $method, string $path, int $statusCod if (is_array($decoded) && isset($decoded['message']) && is_string($decoded['message'])) { $dockerMessage = $decoded['message']; } - - $message = sprintf( - 'Docker API error on %s %s: HTTP %d%s', - $method, - $path, - $statusCode, - $dockerMessage !== null ? sprintf(' — %s', $dockerMessage) : '', - ); - - return new static($message, $statusCode, $dockerMessage, $responseBody); + return new static(sprintf('Docker API error on %s %s: HTTP %d%s', $method, $path, $statusCode, $dockerMessage !== null ? sprintf(' - %s', $dockerMessage) : ''), $statusCode, $dockerMessage, $responseBody); } public function getStatusCode(): int diff --git a/src/Exceptions/DockerConnectionException.php b/src/Exceptions/DockerConnectionException.php index 1525e0e..c5ff054 100644 --- a/src/Exceptions/DockerConnectionException.php +++ b/src/Exceptions/DockerConnectionException.php @@ -6,24 +6,16 @@ use Throwable; -/** - * Thrown when the transport could not reach the Docker Engine at all - * (missing socket, connection refused, timeout, ...). This is a - * transport-level failure, not an API error response. - */ +/** Thrown when the transport could not reach the Docker Engine at all (missing socket, connection refused, timeout, ...). This is a transport-level failure, not an API error response. */ class DockerConnectionException extends DockerException { public static function fromCurlError(string $socketPath, string $curlError, int $curlErrno, ?Throwable $previous = null): self { - return new self( - sprintf( - 'Could not connect to Docker Engine via socket "%s": %s (curl errno %d)', - $socketPath, - $curlError, - $curlErrno, - ), - $curlErrno, - $previous, - ); + return new self(sprintf('Could not connect to Docker Engine via socket "%s": %s (curl errno %d)', $socketPath, $curlError, $curlErrno), $curlErrno, $previous); + } + + public static function fromGuzzleError(string $socketPath, string $guzzleError, ?Throwable $previous = null): self + { + return new self(sprintf('Could not connect to Docker Engine via socket "%s": %s', $socketPath, $guzzleError), 0, $previous); } } diff --git a/src/Exceptions/DockerException.php b/src/Exceptions/DockerException.php index f796d71..59f5381 100644 --- a/src/Exceptions/DockerException.php +++ b/src/Exceptions/DockerException.php @@ -9,6 +9,4 @@ /** * Base exception for all errors raised by this package. */ -class DockerException extends RuntimeException -{ -} +class DockerException extends RuntimeException {} diff --git a/src/Exceptions/DockerNotFoundException.php b/src/Exceptions/DockerNotFoundException.php index faae561..0385751 100644 --- a/src/Exceptions/DockerNotFoundException.php +++ b/src/Exceptions/DockerNotFoundException.php @@ -8,6 +8,4 @@ * Thrown for HTTP 404 responses, e.g. inspecting a container, image, * network or volume that does not exist. */ -class DockerNotFoundException extends DockerApiException -{ -} +class DockerNotFoundException extends DockerApiException {} diff --git a/src/Http/DockerResponse.php b/src/Http/DockerResponse.php index f63a3ca..382ef4a 100644 --- a/src/Http/DockerResponse.php +++ b/src/Http/DockerResponse.php @@ -11,15 +11,11 @@ */ final class DockerResponse { - /** @var array|null */ + /** @var array|null */ private ?array $decoded = null; private bool $decodeAttempted = false; - public function __construct( - private readonly int $statusCode, - private readonly string $body, - ) { - } + public function __construct(private readonly int $statusCode, private readonly string $body) {} public function getStatusCode(): int { @@ -37,30 +33,24 @@ public function isSuccessful(): bool } /** - * Lazily JSON-decodes the response body as an associative array. - * Returns null when the body is empty or not valid JSON. + * Lazily JSON-decodes the response body as an associative array. Returns null when the body is empty or not valid JSON. * - * @return array|null + * @return array|null */ public function json(): ?array { if ($this->decodeAttempted) { return $this->decoded; } - $this->decodeAttempted = true; - if (trim($this->body) === '') { return $this->decoded = null; } - try { - /** @var mixed $decoded */ $decoded = json_decode($this->body, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException) { return $this->decoded = null; } - - return $this->decoded = is_array($decoded) ? $decoded : null; + return $this->decoded = (is_array($decoded) ? $decoded : null); } } diff --git a/src/Http/DockerTransport.php b/src/Http/DockerTransport.php index 50041b4..918f2df 100644 --- a/src/Http/DockerTransport.php +++ b/src/Http/DockerTransport.php @@ -6,6 +6,7 @@ use Closure; use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\RequestException; use JsonException; use Psr\Http\Message\ResponseInterface; @@ -16,15 +17,10 @@ use Sytxlabs\Dockphp\Exceptions\DockerNotFoundException; /** - * Sends requests to the Docker Engine API using Guzzle, either over a - * Unix domain socket (default) or a TCP host (optionally with TLS). - * No shell commands, no Docker CLI. + * Sends requests to the Docker Engine API using Guzzle, either over a Unix domain socket (default) or a TCP host (optionally with TLS). No shell commands, no Docker CLI. * - * For the Unix socket case, the internal base URL "http://localhost" - * is never actually resolved over the network — the CURLOPT_UNIX_SOCKET_PATH - * option (passed through Guzzle's "curl" client config) routes the - * connection through the socket instead, the host part only satisfies - * HTTP syntax. + * For the Unix socket case, the internal base URL "http://localhost" is never actually resolved over the network + * the CURLOPT_UNIX_SOCKET_PATH option (passed through Guzzle's "curl" client config) routes the connection through the socket instead, the host part only satisfies HTTP syntax. * * @phpstan-consistent-constructor */ @@ -33,28 +29,14 @@ class DockerTransport implements DockerTransportInterface private ?string $resolvedApiVersion; private readonly Client $client; - public function __construct( - private readonly ?string $socketPath, - ?string $apiVersion = null, - private readonly float $connectTimeout = 5.0, - private readonly float $timeout = 30.0, - private readonly ?string $host = null, - private readonly int $port = 2375, - private readonly bool $tls = false, - private readonly ?string $caFile = null, - private readonly ?string $certFile = null, - private readonly ?string $keyFile = null, - ) { + public function __construct(private readonly ?string $socketPath, ?string $apiVersion = null, private readonly float $connectTimeout = 5.0, private readonly float $timeout = 30.0, private readonly ?string $host = null, private readonly int $port = 2375, private readonly bool $tls = false, private readonly ?string $caFile = null, private readonly ?string $certFile = null, private readonly ?string $keyFile = null) + { $this->resolvedApiVersion = $apiVersion; $this->client = $this->buildClient(); } - public static function forSocket( - string $socketPath, - ?string $apiVersion = null, - float $connectTimeout = 5.0, - float $timeout = 30.0, - ): static { + public static function forSocket(string $socketPath, ?string $apiVersion = null, float $connectTimeout = 5.0, float $timeout = 30.0): static + { return new static($socketPath, $apiVersion, $connectTimeout, $timeout); } @@ -64,24 +46,14 @@ public static function forSocket( * @param string|null $certFile Path to the client certificate (mutual TLS). * @param string|null $keyFile Path to the client private key (mutual TLS). */ - public static function forTcp( - string $host, - int $port = 2375, - bool $tls = false, - ?string $caFile = null, - ?string $certFile = null, - ?string $keyFile = null, - ?string $apiVersion = null, - float $connectTimeout = 5.0, - float $timeout = 30.0, - ): static { + public static function forTcp(string $host, int $port = 2375, bool $tls = false, ?string $caFile = null, ?string $certFile = null, ?string $keyFile = null, ?string $apiVersion = null, float $connectTimeout = 5.0, float $timeout = 30.0): static + { return new static(null, $apiVersion, $connectTimeout, $timeout, $host, $port, $tls, $caFile, $certFile, $keyFile); } public function request(string $method, string $path, ?array $body = null, array $query = [], array $extraHeaders = []): DockerResponse { [$payload, $headers] = $this->encodeJsonPayload($body); - return $this->bufferedRequest($method, $this->prefixPath($path), $payload, [...$headers, ...$extraHeaders], $query); } @@ -93,7 +65,6 @@ public function requestRaw(string $method, string $path, ?string $rawBody, array public function stream(string $method, string $path, ?array $body, array $query, callable $onChunk, array $extraHeaders = []): int { [$payload, $headers] = $this->encodeJsonPayload($body); - return $this->streamedRequest($method, $this->prefixPath($path), $payload, [...$headers, ...$extraHeaders], $query, $onChunk); } @@ -104,31 +75,31 @@ public function streamRaw(string $method, string $path, ?string $rawBody, array public function getApiVersion(): string { - return $this->resolvedApiVersion ??= $this->fetchApiVersion(); + if ($this->resolvedApiVersion !== null) { + return $this->resolvedApiVersion; + } + $data = $this->bufferedRequest('GET', '/version', null, [], [])->json(); + if (!is_array($data) || !isset($data['ApiVersion']) || !is_string($data['ApiVersion'])) { + throw new DockerException('Could not determine Docker Engine API version from /version response.'); + } + return $this->resolvedApiVersion = $data['ApiVersion']; } private function buildClient(): Client { - $config = [ - 'http_errors' => false, - 'connect_timeout' => $this->connectTimeout, - 'timeout' => $this->timeout, - ]; - + $config = ['http_errors' => false, 'connect_timeout' => $this->connectTimeout, 'timeout' => $this->timeout]; if ($this->socketPath !== null) { $config['curl'] = [CURLOPT_UNIX_SOCKET_PATH => $this->socketPath]; } elseif ($this->tls) { + /** @noinspection ProperNullCoalescingOperatorUsageInspection */ $config['verify'] = $this->caFile ?? true; - if ($this->certFile !== null) { $config['cert'] = $this->certFile; } - if ($this->keyFile !== null) { $config['ssl_key'] = $this->keyFile; } } - return new Client($config); } @@ -137,18 +108,6 @@ private function prefixPath(string $path): string return '/v' . $this->getApiVersion() . $path; } - private function fetchApiVersion(): string - { - $response = $this->bufferedRequest('GET', '/version', null, [], []); - $data = $response->json(); - - if (!is_array($data) || !isset($data['ApiVersion']) || !is_string($data['ApiVersion'])) { - throw new DockerException('Could not determine Docker Engine API version from /version response.'); - } - - return $data['ApiVersion']; - } - /** * @param array|null $body * @@ -156,11 +115,7 @@ private function fetchApiVersion(): string */ private function encodeJsonPayload(?array $body): array { - if ($body === null) { - return [null, []]; - } - - return [$this->encodeJson($body), ['Content-Type: application/json']]; + return $body === null ? [null, []] : [$this->encodeJson($body), ['Content-Type: application/json']]; } /** @@ -174,20 +129,16 @@ private function bufferedRequest(string $method, string $path, ?string $payload, if ($payload !== null) { $options['body'] = $payload; } - try { $response = $this->client->request($method, $this->buildUrl($path, $query), $options); - } catch (RequestException $e) { + } catch (RequestException|GuzzleException $e) { throw $this->connectionExceptionFrom($e); } - $statusCode = $response->getStatusCode(); $body = (string) $response->getBody(); - if ($statusCode >= 400) { $this->throwForError($method, $path, $statusCode, $body); } - return new DockerResponse($statusCode, $body); } @@ -198,6 +149,7 @@ private function bufferedRequest(string $method, string $path, ?string $payload, */ private function streamedRequest(string $method, string $path, ?string $payload, array $headerLines, array $query, callable $onChunk): int { + /** @noinspection PhpClosureCanBeConvertedToFirstClassCallableInspection */ $sink = new StreamingSink($onChunk instanceof Closure ? $onChunk : Closure::fromCallable($onChunk)); $options = [ @@ -213,65 +165,45 @@ private function streamedRequest(string $method, string $path, ?string $payload, if ($payload !== null) { $options['body'] = $payload; } - try { $response = $this->client->request($method, $this->buildUrl($path, $query), $options); - } catch (RequestException $e) { - $context = $e->getHandlerContext(); - $errno = (int) ($context['errno'] ?? 0); - - if ($sink->isAborted() && $errno === CURLE_WRITE_ERROR) { - $statusCode = (int) ($context['http_code'] ?? 0); - - if ($statusCode >= 400) { - $this->throwForError($method, $path, $statusCode, $sink->getErrorBody()); + } catch (RequestException|GuzzleException $e) { + if ($e instanceof RequestException && $e->hasResponse()) { + $context = $e->getHandlerContext(); + if ((int) ($context['errno'] ?? 0) === CURLE_WRITE_ERROR && $sink->isAborted()) { + $statusCode = (int) ($context['http_code'] ?? 0); + if ($statusCode >= 400) { + $this->throwForError($method, $path, $statusCode, $sink->getErrorBody()); + } + return $statusCode; } - - return $statusCode; } - throw $this->connectionExceptionFrom($e); } - $statusCode = $response->getStatusCode(); - if ($statusCode >= 400) { $this->throwForError($method, $path, $statusCode, $sink->getErrorBody()); } - return $statusCode; } - private function connectionExceptionFrom(RequestException $e): DockerConnectionException + private function connectionExceptionFrom(RequestException|GuzzleException $e): DockerConnectionException { + $target = $this->socketPath ?? (($this->tls ? 'tcps://' : 'tcp://') . $this->host . ':' . $this->port); + if (!($e instanceof RequestException)) { + return DockerConnectionException::fromGuzzleError($target, $e->getMessage(), $e); + } $context = $e->getHandlerContext(); - $errno = (int) ($context['errno'] ?? 0); - $error = (string) ($context['error'] ?? $e->getMessage()); - - return DockerConnectionException::fromCurlError($this->describeTarget(), $error, $errno); + return DockerConnectionException::fromCurlError($target, (string) ($context['error'] ?? $e->getMessage()), (int) ($context['errno'] ?? 0)); } private function throwForError(string $method, string $path, int $statusCode, string $body): never { $exceptionClass = $statusCode === 404 ? DockerNotFoundException::class : DockerApiException::class; - throw $exceptionClass::fromResponse($method, $path, $statusCode, $body); } - private function describeTarget(): string - { - if ($this->socketPath !== null) { - return $this->socketPath; - } - - return ($this->tls ? 'tcps://' : 'tcp://') . $this->host . ':' . $this->port; - } - /** - * Converts this package's raw "Name: value" header line format - * (used throughout its public interface) into the associative - * array Guzzle's "headers" request option expects. - * * @param array $headerLines * * @return array @@ -279,78 +211,52 @@ private function describeTarget(): string private function toAssocHeaders(array $headerLines): array { $headers = []; - foreach ($headerLines as $line) { $pos = strpos($line, ':'); - if ($pos === false) { continue; } - $headers[trim(substr($line, 0, $pos))] = trim(substr($line, $pos + 1)); } - return $headers; } - /** - * @param array $query - */ + /** @param array $query */ protected function buildUrl(string $path, array $query): string { $queryString = $this->buildQueryString($query); - $base = $this->socketPath !== null - ? 'http://localhost' - : ($this->tls ? 'https' : 'http') . '://' . $this->host . ':' . $this->port; - - return $base . $path . ($queryString !== '' ? '?' . $queryString : ''); + return ($this->socketPath !== null ? 'http://localhost' : ($this->tls ? 'https' : 'http') . '://' . $this->host . ':' . $this->port) . $path . ($queryString !== '' ? '?' . $queryString : ''); } /** - * Docker's query parameters use JSON encoding for array values - * (e.g. `filters`) and lowercase "true"/"false" for booleans. + * Docker's query parameters use JSON encoding for array values (e.g. `filters`) and lowercase "true"/"false" for booleans. * * @param array $query */ protected function buildQueryString(array $query): string { $prepared = []; - foreach ($query as $key => $value) { if ($value === null) { continue; } - if (is_bool($value)) { $prepared[$key] = $value ? 'true' : 'false'; continue; } - if (is_array($value)) { $prepared[$key] = $this->encodeJson($value); continue; } - $prepared[$key] = (string) $value; } - if ($prepared === []) { return ''; } - return http_build_query($prepared, '', '&', PHP_QUERY_RFC3986); } - /** - * An empty PHP array is indistinguishable from an empty list, so it - * json_encode()s to `[]` by default. Docker's Go structs expect a - * JSON object (`{}`) wherever a body or query value is a map (e.g. - * exec start options, `filters`) — encode empty arrays as `{}` - * instead of `[]` to match. Non-empty arrays are unaffected: their - * PHP key types already determine object-vs-list encoding. - * - * @param array $data - */ + /** @param array $data */ protected function encodeJson(array $data): string { try { diff --git a/src/Http/DockerTransportInterface.php b/src/Http/DockerTransportInterface.php index bde9eea..9385319 100644 --- a/src/Http/DockerTransportInterface.php +++ b/src/Http/DockerTransportInterface.php @@ -7,10 +7,7 @@ use Sytxlabs\Dockphp\Exceptions\DockerApiException; use Sytxlabs\Dockphp\Exceptions\DockerConnectionException; -/** - * Contract used by Resources to talk to the Docker Engine API. - * Allows tests to substitute a fake transport with no real socket. - */ +/** Contract used by Resources to talk to the Docker Engine API. Allows tests to substitute a fake transport with no real socket. */ interface DockerTransportInterface { /** @@ -21,35 +18,18 @@ interface DockerTransportInterface * @throws DockerConnectionException When the socket cannot be reached. * @throws DockerApiException When the Engine responds with a non-2xx status. */ - public function request( - string $method, - string $path, - ?array $body = null, - array $query = [], - array $extraHeaders = [], - ): DockerResponse; + public function request(string $method, string $path, ?array $body = null, array $query = [], array $extraHeaders = []): DockerResponse; /** - * Like request(), but sends $rawBody verbatim (no JSON encoding). - * Used for endpoints that take a tar stream (e.g. copying files - * into a container). + * Like request(), but sends $rawBody verbatim (no JSON encoding). Used for endpoints that take a tar stream (e.g. copying files into a container). * * @param array $query * @param array $headers Extra request headers, e.g. 'Content-Type: application/x-tar'. */ - public function requestRaw( - string $method, - string $path, - ?string $rawBody, - array $query = [], - array $headers = [], - ): DockerResponse; + public function requestRaw(string $method, string $path, ?string $rawBody, array $query = [], array $headers = []): DockerResponse; /** - * Streams the response body to $onChunk as it arrives instead of - * buffering it. Used for `follow`ed logs, streamed stats, and - * `/events`. Returning false from $onChunk stops the transfer - * early without throwing. + * Streams the response body to $onChunk as it arrives instead of buffering it. Used for `follow`ed logs, streamed stats, and `/events`. Returning false from $onChunk stops the transfer early without throwing. * * @param array|null $body * @param array $query @@ -58,36 +38,17 @@ public function requestRaw( * * @return int The final HTTP status code. */ - public function stream( - string $method, - string $path, - ?array $body, - array $query, - callable $onChunk, - array $extraHeaders = [], - ): int; + public function stream(string $method, string $path, ?array $body, array $query, callable $onChunk, array $extraHeaders = []): int; /** - * Combination of requestRaw() and stream(): raw request body, - * streamed response. Used only for `docker build`. + * Combination of requestRaw() and stream(): raw request body, streamed response. Used only for `docker build`. * * @param array $query * @param callable(string): (bool|void) $onChunk * @param array $headers */ - public function streamRaw( - string $method, - string $path, - ?string $rawBody, - array $query, - callable $onChunk, - array $headers = [], - ): int; + public function streamRaw(string $method, string $path, ?string $rawBody, array $query, callable $onChunk, array $headers = []): int; - /** - * Resolves the API version prefix used for requests (e.g. "1.43"), - * either the manually configured override or the version detected - * lazily via a one-time `/version` call. - */ + /** Resolves the API version prefix used for requests (e.g. "1.43"), either the manually configured override or the version detected lazily via a one-time `/version` call. */ public function getApiVersion(): string; } diff --git a/src/Http/StreamingSink.php b/src/Http/StreamingSink.php index 5effbf4..04179cb 100644 --- a/src/Http/StreamingSink.php +++ b/src/Http/StreamingSink.php @@ -8,27 +8,6 @@ use Psr\Http\Message\StreamInterface; use RuntimeException; -/** - * Write-only PSR-7 stream used as Guzzle's cURL "sink" for streamed - * requests (follow logs, live stats, pull/build progress, events). - * - * Guzzle's default cURL handler is synchronous (curl_exec() blocks - * until the transfer completes) but still writes each body chunk to - * the sink as it arrives via CURLOPT_WRITEFUNCTION — so a sink that - * forwards to a callback on write() gives genuine incremental - * processing during that blocking call, exactly like a raw cURL - * write-function would. Returning false from the callback makes - * write() return a short byte count, which curl reports as - * CURLE_WRITE_ERROR and aborts the transfer — the caller translates - * that back into a clean early stop instead of an exception. - * - * When the response turns out to be an error (status >= 400, flagged - * via markAsError() from an `on_headers` callback before any body - * bytes arrive), bytes are buffered instead of forwarded, so the - * caller can build a proper exception message from the full body. - * - * @internal - */ final class StreamingSink implements StreamInterface { private bool $isError = false; @@ -38,9 +17,7 @@ final class StreamingSink implements StreamInterface /** * @param Closure(string): (bool|void) $onChunk */ - public function __construct(private readonly Closure $onChunk) - { - } + public function __construct(private readonly Closure $onChunk) {} public function markAsError(): void { @@ -79,9 +56,7 @@ public function __toString(): string return ''; } - public function close(): void - { - } + public function close(): void {} public function detach(): mixed { diff --git a/src/Resources/AbstractResource.php b/src/Resources/AbstractResource.php index 9e7e585..827ba7c 100644 --- a/src/Resources/AbstractResource.php +++ b/src/Resources/AbstractResource.php @@ -10,27 +10,17 @@ abstract class AbstractResource { - public function __construct( - protected readonly DockerTransportInterface $transport, - ) { - } + public function __construct(protected readonly DockerTransportInterface $transport) {} /** - * Decodes a successful response body as a JSON object, for - * hydrating DTOs. Throws if the Engine unexpectedly returned an - * empty or non-object body on a 2xx response. + * Decodes a successful response body as a JSON object, for hydrating DTOs. Throws if the Engine unexpectedly returned an empty or non-object body on a 2xx response. * * @return array */ protected function decodeObject(DockerResponse $response): array { $data = $response->json(); - - if ($data === null) { - throw new DockerException('Expected a JSON object in the Docker Engine response, got an empty or invalid body.'); - } - - return $data; + return $data ?? throw new DockerException('Expected a JSON object in the Docker Engine response, got an empty or invalid body.'); } /** @@ -41,14 +31,6 @@ protected function decodeObject(DockerResponse $response): array protected function decodeList(DockerResponse $response): array { $data = $response->json(); - - if ($data === null) { - return []; - } - - return array_values(array_map( - static fn (mixed $item): array => is_array($item) ? $item : [], - $data, - )); + return $data !== null ? array_values(array_map(static fn(mixed $item): array => is_array($item) ? $item : [], $data)) : []; } } diff --git a/src/Resources/Containers.php b/src/Resources/Containers.php index 6cb8272..f7e7f97 100644 --- a/src/Resources/Containers.php +++ b/src/Resources/Containers.php @@ -21,25 +21,17 @@ final class Containers extends AbstractResource public function list(array $query = []): array { $response = $this->transport->request('GET', '/containers/json', null, $query); - - return array_map( - static fn (array $item): ContainerSummary => ContainerSummary::fromArray($item), - $this->decodeList($response), - ); + return array_map(static fn(array $item): ContainerSummary => ContainerSummary::fromArray($item), $this->decodeList($response)); } public function inspect(string $id, bool $size = false): ContainerInfo { - $response = $this->transport->request('GET', "/containers/{$id}/json", null, ['size' => $size]); - - return ContainerInfo::fromArray($this->decodeObject($response)); + return ContainerInfo::fromArray($this->decodeObject($this->transport->request('GET', "/containers/{$id}/json", null, ['size' => $size]))); } /** - * Creates a container. Accepts the full Docker container config as - * a single array; 'name' (and 'platform', if present) are split out - * into query parameters as required by the Engine API, the rest is - * sent as the JSON request body. + * Creates a container. Accepts the full Docker container config as a single array; 'name' (and 'platform', if present) + * are split out into query parameters as required by the Engine API, the rest is sent as the JSON request body. * * @param array $config */ @@ -51,43 +43,31 @@ public function create(array $config): DockerResponse $query['name'] = $config['name']; unset($config['name']); } - if (isset($config['platform'])) { $query['platform'] = $config['platform']; unset($config['platform']); } - return $this->transport->request('POST', '/containers/create', $config, $query); } public function start(string $id, ?string $detachKeys = null): DockerResponse { - return $this->transport->request('POST', "/containers/{$id}/start", null, [ - 'detachKeys' => $detachKeys, - ]); + return $this->transport->request('POST', "/containers/{$id}/start", null, ['detachKeys' => $detachKeys]); } public function stop(string $id, ?int $timeoutSeconds = null, ?string $signal = null): DockerResponse { - return $this->transport->request('POST', "/containers/{$id}/stop", null, [ - 't' => $timeoutSeconds, - 'signal' => $signal, - ]); + return $this->transport->request('POST', "/containers/{$id}/stop", null, ['t' => $timeoutSeconds, 'signal' => $signal]); } public function restart(string $id, ?int $timeoutSeconds = null, ?string $signal = null): DockerResponse { - return $this->transport->request('POST', "/containers/{$id}/restart", null, [ - 't' => $timeoutSeconds, - 'signal' => $signal, - ]); + return $this->transport->request('POST', "/containers/{$id}/restart", null, ['t' => $timeoutSeconds, 'signal' => $signal]); } public function kill(string $id, string $signal = 'SIGKILL'): DockerResponse { - return $this->transport->request('POST', "/containers/{$id}/kill", null, [ - 'signal' => $signal, - ]); + return $this->transport->request('POST', "/containers/{$id}/kill", null, ['signal' => $signal]); } public function pause(string $id): DockerResponse @@ -102,41 +82,28 @@ public function unpause(string $id): DockerResponse public function rename(string $id, string $newName): DockerResponse { - return $this->transport->request('POST', "/containers/{$id}/rename", null, [ - 'name' => $newName, - ]); + return $this->transport->request('POST', "/containers/{$id}/rename", null, ['name' => $newName]); } public function remove(string $id, bool $force = false, bool $removeVolumes = false, bool $removeLinks = false): DockerResponse { - return $this->transport->request('DELETE', "/containers/{$id}", null, [ - 'force' => $force, - 'v' => $removeVolumes, - 'link' => $removeLinks, - ]); + return $this->transport->request('DELETE', "/containers/{$id}", null, ['force' => $force, 'v' => $removeVolumes, 'link' => $removeLinks]); } /** - * Returns the full (non-streamed) log output. Note: unless the - * container was created with a TTY, Docker multiplexes stdout and - * stderr into a framed binary format — run the body through - * {@see \Sytxlabs\Dockphp\Support\StdioDemultiplexer::demuxAll()} - * to split it back into per-stream text. + * Returns the full (non-streamed) log output. Note: unless the container was created with a TTY, Docker multiplexes stdout and stderr into a framed binary format run the body through + * {@see \Sytxlabs\Dockphp\Support\StdioDemultiplexer::demuxAll()} to split it back into per-stream text. * * @param array $query Supports 'stdout', 'stderr', 'since', 'until', 'timestamps', 'tail'. */ public function logs(string $id, array $query = []): DockerResponse { $query += ['stdout' => true, 'stderr' => true, 'follow' => false]; - return $this->transport->request('GET', "/containers/{$id}/logs", null, $query); } /** - * Streams log output as it's produced ('follow' forced true). Feed - * chunks through {@see \Sytxlabs\Dockphp\Support\StdioDemultiplexer} - * if the container has no TTY. Return false from $onChunk to stop - * following. + * Streams log output as it's produced ('follow' forced true). Feed chunks through {@see \Sytxlabs\Dockphp\Support\StdioDemultiplexer} if the container has no TTY. Return false from $onChunk to stop following. * * @param array $query Supports 'stdout', 'stderr', 'since', 'timestamps', 'tail'. * @param callable(string): (bool|void) $onChunk @@ -145,72 +112,46 @@ public function logsStream(string $id, callable $onChunk, array $query = []): in { $query += ['stdout' => true, 'stderr' => true]; $query['follow'] = true; - return $this->transport->stream('GET', "/containers/{$id}/logs", null, $query, $onChunk); } - /** - * Single stats snapshot. - */ + /** Single stats snapshot. */ public function stats(string $id): DockerResponse { - return $this->transport->request('GET', "/containers/{$id}/stats", null, [ - 'stream' => false, - ]); + return $this->transport->request('GET', "/containers/{$id}/stats", null, ['stream' => false]); } /** - * Streams stats snapshots continuously. Each chunk is a JSON object; - * see {@see \Sytxlabs\Dockphp\Support\NdjsonLineBuffer} to decode - * them line by line. Return false from $onChunk to stop. + * Streams stats snapshots continuously. Each chunk is a JSON object; see {@see \Sytxlabs\Dockphp\Support\NdjsonLineBuffer} to decode them line by line. Return false from $onChunk to stop. * * @param callable(string): (bool|void) $onChunk */ public function statsStream(string $id, callable $onChunk): int { - return $this->transport->stream('GET', "/containers/{$id}/stats", null, [ - 'stream' => true, - ], $onChunk); + return $this->transport->stream('GET', "/containers/{$id}/stats", null, ['stream' => true], $onChunk); } public function top(string $id, ?string $psArgs = null): DockerResponse { - return $this->transport->request('GET', "/containers/{$id}/top", null, [ - 'ps_args' => $psArgs, - ]); + return $this->transport->request('GET', "/containers/{$id}/top", null, ['ps_args' => $psArgs]); } - /** - * Blocks until the container stops (or the given condition is met), - * then returns the exit status. Relies on the client's configured - * request timeout — pass a longer $timeout to DockerClient if you - * expect a long wait. - */ + /** Blocks until the container stops (or the given condition is met), then returns the exit status. Relies on the client's configured request timeout pass a longer $timeout to DockerClient if you expect a long wait. */ public function wait(string $id, string $condition = 'not-running'): DockerResponse { - return $this->transport->request('POST', "/containers/{$id}/wait", null, [ - 'condition' => $condition, - ]); + return $this->transport->request('POST', "/containers/{$id}/wait", null, ['condition' => $condition]); } - /** - * Lists filesystem changes (added/changed/deleted paths) since the - * container was created. - */ + /** Lists filesystem changes (added/changed/deleted paths) since the container was created. */ public function changes(string $id): DockerResponse { return $this->transport->request('GET', "/containers/{$id}/changes"); } - /** - * Downloads a tar archive of a path inside the container. Read the - * raw tar bytes via DockerResponse::getBody(). - */ + /** Downloads a tar archive of a path inside the container. Read the raw tar bytes via DockerResponse::getBody(). */ public function getArchive(string $id, string $path): DockerResponse { - return $this->transport->request('GET', "/containers/{$id}/archive", null, [ - 'path' => $path, - ]); + return $this->transport->request('GET', "/containers/{$id}/archive", null, ['path' => $path]); } /** @@ -218,34 +159,20 @@ public function getArchive(string $id, string $path): DockerResponse * container. $tarContent must be a valid (optionally gzip/bzip2/xz * compressed) tar stream. */ - public function putArchive( - string $id, - string $path, - string $tarContent, - bool $noOverwriteDirNonDir = false, - bool $copyUIDGID = false, - ): DockerResponse { - return $this->transport->requestRaw('PUT', "/containers/{$id}/archive", $tarContent, [ - 'path' => $path, - 'noOverwriteDirNonDir' => $noOverwriteDirNonDir, - 'copyUIDGID' => $copyUIDGID, - ], ['Content-Type: application/x-tar']); + public function putArchive(string $id, string $path, string $tarContent, bool $noOverwriteDirNonDir = false, bool $copyUIDGID = false): DockerResponse + { + return $this->transport->requestRaw('PUT', "/containers/{$id}/archive", $tarContent, ['path' => $path, 'noOverwriteDirNonDir' => $noOverwriteDirNonDir, 'copyUIDGID' => $copyUIDGID], ['Content-Type: application/x-tar']); } - /** - * Exports the entire container filesystem as a tar archive. Read - * the raw tar bytes via DockerResponse::getBody(). - */ + /** Exports the entire container filesystem as a tar archive. Read the raw tar bytes via DockerResponse::getBody(). */ public function export(string $id): DockerResponse { return $this->transport->request('GET', "/containers/{$id}/export"); } /** - * Streams a container's stdout/stderr (read-only). This is not a - * full interactive attach — stdin cannot be interleaved with - * reading output over a single request/response cURL call; use - * $stdin to send one fixed block of input up front if needed. + * Streams a container's stdout/stderr (read-only). This is not a full interactive attach stdin cannot be interleaved with reading output over a single request/response cURL call; + * use $stdin to send one fixed block of input up front if needed. * * @param array $query Supports 'stdout', 'stderr', 'stream', 'logs'. * @param callable(string): (bool|void) $onChunk @@ -253,7 +180,6 @@ public function export(string $id): DockerResponse public function attachStream(string $id, callable $onChunk, array $query = []): int { $query += ['stdout' => true, 'stderr' => true, 'stream' => true, 'logs' => false]; - return $this->transport->stream('POST', "/containers/{$id}/attach", null, $query, $onChunk); } @@ -264,14 +190,11 @@ public function attachStream(string $id, callable $onChunk, array $query = []): */ public function prune(array $filters = []): DockerResponse { - return $this->transport->request('POST', '/containers/prune', null, [ - 'filters' => $filters === [] ? null : $filters, - ]); + return $this->transport->request('POST', '/containers/prune', null, ['filters' => $filters === [] ? null : $filters]); } /** - * Updates a running (or stopped) container's resource limits without - * recreating it. + * Updates a running (or stopped) container's resource limits without recreating it. * * @param array $resources Supports e.g. 'Memory', 'MemorySwap', 'CpuShares', 'CpuPeriod', 'CpuQuota', 'RestartPolicy'. */ diff --git a/src/Resources/Exec.php b/src/Resources/Exec.php index 27256a1..a568ebb 100644 --- a/src/Resources/Exec.php +++ b/src/Resources/Exec.php @@ -6,21 +6,11 @@ use Sytxlabs\Dockphp\Http\DockerResponse; -/** - * `docker exec` equivalent: run a new command inside a running - * container. Output-only (read) streaming is supported via - * startStream(); a fully interactive session (writing to stdin while - * reading output) is out of scope — that needs a raw duplex socket, - * which a per-call HTTP client like this one cannot provide. - * - * @see https://docs.docker.com/engine/api/latest/#tag/Exec - */ +/** @see https://docs.docker.com/engine/api/latest/#tag/Exec */ final class Exec extends AbstractResource { /** - * Creates an exec instance for a running container. Returns the - * new exec instance's Id (via `$response->json()['Id']`) — it must - * be started with start()/startStream() to actually run. + * Creates an exec instance for a running container. Returns the new exec instance's Id (via `$response->json()['Id']`) it must be started with start()/startStream() to actually run. * * @param array $config Supports e.g. 'Cmd', 'AttachStdout', 'AttachStderr', 'AttachStdin', 'Tty', 'Env', 'WorkingDir', 'User', 'Privileged'. */ @@ -30,10 +20,8 @@ public function create(string $containerId, array $config): DockerResponse } /** - * Starts (runs) a previously created exec instance and returns its - * full, non-streamed output. Unless the exec was created with - * Tty=true, the output is multiplexed — see - * {@see \Sytxlabs\Dockphp\Support\StdioDemultiplexer}. + * Starts (runs) a previously created exec instance and returns its full, non-streamed output. + * Unless the exec was created with Tty=true, the output is multiplexed - see {@see \Sytxlabs\Dockphp\Support\StdioDemultiplexer}. * * @param array $options Supports 'Detach', 'Tty'. */ @@ -43,8 +31,7 @@ public function start(string $execId, array $options = []): DockerResponse } /** - * Like start(), but streams output to $onChunk as it's produced. - * Return false from $onChunk to stop reading early. + * Like start(), but streams output to $onChunk as it's produced. Return false from $onChunk to stop reading early. * * @param array $options Supports 'Tty'; 'Detach' is forced false. * @param callable(string): (bool|void) $onChunk @@ -52,19 +39,13 @@ public function start(string $execId, array $options = []): DockerResponse public function startStream(string $execId, callable $onChunk, array $options = []): int { $options['Detach'] = false; - return $this->transport->stream('POST', "/exec/{$execId}/start", $options, [], $onChunk); } - /** - * Resizes the TTY of a running exec instance. - */ + /** Resizes the TTY of a running exec instance. */ public function resize(string $execId, int $height, int $width): DockerResponse { - return $this->transport->request('POST', "/exec/{$execId}/resize", null, [ - 'h' => $height, - 'w' => $width, - ]); + return $this->transport->request('POST', "/exec/{$execId}/resize", null, ['h' => $height, 'w' => $width]); } public function inspect(string $execId): DockerResponse diff --git a/src/Resources/Images.php b/src/Resources/Images.php index 3c1b883..b2dca95 100644 --- a/src/Resources/Images.php +++ b/src/Resources/Images.php @@ -4,6 +4,8 @@ namespace Sytxlabs\Dockphp\Resources; +use JsonException; +use RuntimeException; use Sytxlabs\Dockphp\DTO\ImageInfo; use Sytxlabs\Dockphp\DTO\ImageSummary; use Sytxlabs\Dockphp\Http\DockerResponse; @@ -23,23 +25,16 @@ public function list(array $query = []): array { $response = $this->transport->request('GET', '/images/json', null, $query); - return array_map( - static fn (array $item): ImageSummary => ImageSummary::fromArray($item), - $this->decodeList($response), - ); + return array_map(static fn(array $item): ImageSummary => ImageSummary::fromArray($item), $this->decodeList($response)); } public function inspect(string $name): ImageInfo { - $response = $this->transport->request('GET', "/images/{$name}/json"); - - return ImageInfo::fromArray($this->decodeObject($response)); + return ImageInfo::fromArray($this->decodeObject($this->transport->request('GET', "/images/{$name}/json"))); } /** - * Pulls (creates) an image from a registry. Returns the full, - * non-streamed body of newline-delimited JSON progress events. - * Use pullStream() to react to progress as it happens. + * Pulls (creates) an image from a registry. Returns the full, non-streamed body of newline-delimited JSON progress events. Use pullStream() to react to progress as it happens. * * @param array|null $registryAuth Credentials for a private registry, e.g. ['username' => ..., 'password' => ..., 'serveraddress' => ...] or ['identitytoken' => ...]. */ @@ -53,12 +48,10 @@ public function pull(string $name, ?string $tag = null, ?string $platform = null } /** - * Pulls an image, invoking $onProgress with each decoded progress - * event as it arrives. Return false from $onProgress to abort the - * pull early. + * Pulls an image, invoking $onProgress with each decoded progress event as it arrives. Return false from $onProgress to abort the pull early. * * @param array|null $registryAuth See pull(). - * @param callable(array): (bool|void) $onProgress + * @param callable(array): (bool|void) $onProgress */ public function pullStream(string $name, ?string $tag, ?string $platform, callable $onProgress, ?array $registryAuth = null): int { @@ -68,37 +61,29 @@ public function pullStream(string $name, ?string $tag, ?string $platform, callab 'fromImage' => $name, 'tag' => $tag, 'platform' => $platform, - ], static fn (string $chunk): bool => $lineBuffer->push($chunk, $onProgress), $this->registryAuthHeader($registryAuth)); + ], static fn(string $chunk): bool => $lineBuffer->push($chunk, $onProgress), $this->registryAuthHeader($registryAuth)); } /** - * Pushes an image to a registry. Returns the full, non-streamed - * body of newline-delimited JSON progress events. Use - * pushStream() to react to progress as it happens. + * Pushes an image to a registry. Returns the full, non-streamed body of newline-delimited JSON progress events. Use pushStream() to react to progress as it happens. * * @param array|null $registryAuth See pull(). Required by most registries. */ public function push(string $name, ?string $tag = null, ?array $registryAuth = null): DockerResponse { - return $this->transport->request('POST', "/images/{$name}/push", null, [ - 'tag' => $tag, - ], $this->registryAuthHeader($registryAuth)); + return $this->transport->request('POST', "/images/{$name}/push", null, ['tag' => $tag], $this->registryAuthHeader($registryAuth)); } /** - * Like push(), but invokes $onProgress with each decoded progress - * event as it arrives. Return false from $onProgress to abort. + * Like push(), but invokes $onProgress with each decoded progress event as it arrives. Return false from $onProgress to abort. * * @param array|null $registryAuth See pull(). - * @param callable(array): (bool|void) $onProgress + * @param callable(array): (bool|void) $onProgress */ public function pushStream(string $name, callable $onProgress, ?string $tag = null, ?array $registryAuth = null): int { $lineBuffer = new NdjsonLineBuffer(); - - return $this->transport->stream('POST', "/images/{$name}/push", null, [ - 'tag' => $tag, - ], static fn (string $chunk): bool => $lineBuffer->push($chunk, $onProgress), $this->registryAuthHeader($registryAuth)); + return $this->transport->stream('POST', "/images/{$name}/push", null, ['tag' => $tag], static fn(string $chunk): bool => $lineBuffer->push($chunk, $onProgress), $this->registryAuthHeader($registryAuth)); } /** @@ -108,27 +93,19 @@ public function pushStream(string $name, callable $onProgress, ?string $tag = nu */ public function search(string $term, ?int $limit = null, array $filters = []): DockerResponse { - return $this->transport->request('GET', '/images/search', null, [ - 'term' => $term, - 'limit' => $limit, - 'filters' => $filters === [] ? null : $filters, - ]); + return $this->transport->request('GET', '/images/search', null, ['term' => $term, 'limit' => $limit, 'filters' => $filters === [] ? null : $filters]); } /** - * Imports one or more images from a tar archive previously - * produced by save()/saveMultiple() (or `docker save`). + * Imports one or more images from a tar archive previously produced by save()/saveMultiple() (or `docker save`). */ public function load(string $tarContent, bool $quiet = false): DockerResponse { - return $this->transport->requestRaw('POST', '/images/load', $tarContent, [ - 'quiet' => $quiet, - ], ['Content-Type: application/x-tar']); + return $this->transport->requestRaw('POST', '/images/load', $tarContent, ['quiet' => $quiet], ['Content-Type: application/x-tar']); } /** - * Exports a single image (with its history) as a tar archive. - * Read the raw tar bytes via DockerResponse::getBody(). + * Exports a single image (with its history) as a tar archive. Read the raw tar bytes via DockerResponse::getBody(). */ public function save(string $name): DockerResponse { @@ -136,30 +113,18 @@ public function save(string $name): DockerResponse } /** - * Exports multiple images (and their shared layers) as a single - * tar archive. Read the raw tar bytes via DockerResponse::getBody(). - * - * Docker expects the repeated `names=a&names=b` query format here - * rather than the JSON-array-per-key format used everywhere else - * (e.g. `filters`), so the query string is built manually. + * Exports multiple images (and their shared layers) as a single tar archive. Read the raw tar bytes via DockerResponse::getBody(). + * Docker expects the repeated `names=a&names=b` query format here rather than the JSON-array-per-key format used everywhere else (e.g. `filters`), so the query string is built manually. * * @param list $names */ public function saveMultiple(array $names): DockerResponse { - $namesQuery = implode('&', array_map( - static fn (string $name): string => 'names=' . rawurlencode($name), - $names, - )); - + $namesQuery = implode('&', array_map(static fn(string $name): string => 'names=' . rawurlencode($name), $names)); return $this->transport->request('GET', '/images/get' . ($namesQuery !== '' ? '?' . $namesQuery : '')); } - /** - * Returns distribution (registry) information for an image - * reference without pulling it — useful to check a digest or - * available platforms up front. - */ + /** Returns distribution (registry) information for an image reference without pulling it useful to check a digest or available platforms up front. */ public function inspectDistribution(string $name): DockerResponse { return $this->transport->request('GET', "/distribution/{$name}/json"); @@ -175,8 +140,11 @@ private function registryAuthHeader(?array $registryAuth): array if ($registryAuth === null) { return []; } - - return ['X-Registry-Auth: ' . base64_encode(json_encode($registryAuth, JSON_THROW_ON_ERROR))]; + try { + return ['X-Registry-Auth: ' . base64_encode(json_encode($registryAuth, JSON_THROW_ON_ERROR))]; + } catch (JsonException $e) { + throw new RuntimeException('Failed to encode registry auth as JSON: ' . $e->getMessage(), 0, $e); + } } /** @@ -185,16 +153,8 @@ private function registryAuthHeader(?array $registryAuth): array * @param list $changes Dockerfile-style instructions to apply while committing (e.g. 'CMD ["/app"]'). * @param array|null $config Optional container config to merge into the resulting image. */ - public function commit( - string $containerId, - ?string $repo = null, - ?string $tag = null, - ?string $comment = null, - ?string $author = null, - bool $pause = true, - array $changes = [], - ?array $config = null, - ): DockerResponse { + public function commit(string $containerId, ?string $repo = null, ?string $tag = null, ?string $comment = null, ?string $author = null, bool $pause = true, array $changes = [], ?array $config = null): DockerResponse + { return $this->transport->request('POST', '/commit', $config, [ 'container' => $containerId, 'repo' => $repo, @@ -207,11 +167,8 @@ public function commit( } /** - * Builds an image from a tar-encoded build context (the same - * archive `docker build` sends — a directory containing a - * Dockerfile, tarred but not compressed, or gzip/bzip2/xz - * compressed). Returns the full, non-streamed build log body. - * Use buildStream() to react to build progress as it happens. + * Builds an image from a tar-encoded build context (the same archive `docker build` sends a directory containing a Dockerfile, tarred but not compressed, or gzip/bzip2/xz compressed). + * Returns the full, non-streamed build log body. Use buildStream() to react to build progress as it happens. * * @param array $query Supports e.g. 't' (tag), 'dockerfile', 'nocache', 'buildargs' (as a JSON-encoded string), 'platform'. */ @@ -225,36 +182,22 @@ public function build(string $tarContent, array $query = []): DockerResponse * log line as it arrives. Return false from $onProgress to abort. * * @param array $query - * @param callable(array): (bool|void) $onProgress + * @param callable(array): (bool|void) $onProgress */ public function buildStream(string $tarContent, callable $onProgress, array $query = []): int { $lineBuffer = new NdjsonLineBuffer(); - - return $this->transport->streamRaw( - 'POST', - '/build', - $tarContent, - $query, - static fn (string $chunk): bool => $lineBuffer->push($chunk, $onProgress), - ['Content-Type: application/x-tar'], - ); + return $this->transport->streamRaw('POST', '/build', $tarContent, $query, static fn(string $chunk): bool => $lineBuffer->push($chunk, $onProgress), ['Content-Type: application/x-tar']); } public function remove(string $name, bool $force = false, bool $noprune = false): DockerResponse { - return $this->transport->request('DELETE', "/images/{$name}", null, [ - 'force' => $force, - 'noprune' => $noprune, - ]); + return $this->transport->request('DELETE', "/images/{$name}", null, ['force' => $force, 'noprune' => $noprune]); } public function tag(string $name, string $repo, ?string $tag = null): DockerResponse { - return $this->transport->request('POST', "/images/{$name}/tag", null, [ - 'repo' => $repo, - 'tag' => $tag, - ]); + return $this->transport->request('POST', "/images/{$name}/tag", null, ['repo' => $repo, 'tag' => $tag]); } public function history(string $name): DockerResponse @@ -262,13 +205,9 @@ public function history(string $name): DockerResponse return $this->transport->request('GET', "/images/{$name}/history"); } - /** - * @param array> $filters - */ + /** @param array> $filters */ public function prune(array $filters = []): DockerResponse { - return $this->transport->request('POST', '/images/prune', null, [ - 'filters' => $filters === [] ? null : $filters, - ]); + return $this->transport->request('POST', '/images/prune', null, ['filters' => $filters === [] ? null : $filters]); } } diff --git a/src/Resources/Networks.php b/src/Resources/Networks.php index 107f957..bb46adf 100644 --- a/src/Resources/Networks.php +++ b/src/Resources/Networks.php @@ -19,23 +19,13 @@ final class Networks extends AbstractResource */ public function list(array $filters = []): array { - $response = $this->transport->request('GET', '/networks', null, [ - 'filters' => $filters === [] ? null : $filters, - ]); - - return array_map( - static fn (array $item): NetworkInfo => NetworkInfo::fromArray($item), - $this->decodeList($response), - ); + $response = $this->transport->request('GET', '/networks', null, ['filters' => $filters === [] ? null : $filters]); + return array_map(static fn(array $item): NetworkInfo => NetworkInfo::fromArray($item), $this->decodeList($response)); } public function inspect(string $id, bool $verbose = false, ?string $scope = null): NetworkInfo { - $response = $this->transport->request('GET', "/networks/{$id}", null, [ - 'verbose' => $verbose, - 'scope' => $scope, - ]); - + $response = $this->transport->request('GET', "/networks/{$id}", null, ['verbose' => $verbose, 'scope' => $scope]); return NetworkInfo::fromArray($this->decodeObject($response)); } @@ -45,7 +35,6 @@ public function inspect(string $id, bool $verbose = false, ?string $scope = null public function create(string $name, array $options = []): DockerResponse { $body = ['Name' => $name] + $options; - return $this->transport->request('POST', '/networks/create', $body); } @@ -55,20 +44,15 @@ public function create(string $name, array $options = []): DockerResponse public function connect(string $id, string $containerId, array $endpointConfig = []): DockerResponse { $body = ['Container' => $containerId]; - if ($endpointConfig !== []) { $body['EndpointConfig'] = $endpointConfig; } - return $this->transport->request('POST', "/networks/{$id}/connect", $body); } public function disconnect(string $id, string $containerId, bool $force = false): DockerResponse { - return $this->transport->request('POST', "/networks/{$id}/disconnect", [ - 'Container' => $containerId, - 'Force' => $force, - ]); + return $this->transport->request('POST', "/networks/{$id}/disconnect", ['Container' => $containerId, 'Force' => $force]); } public function remove(string $id): DockerResponse @@ -81,8 +65,6 @@ public function remove(string $id): DockerResponse */ public function prune(array $filters = []): DockerResponse { - return $this->transport->request('POST', '/networks/prune', null, [ - 'filters' => $filters === [] ? null : $filters, - ]); + return $this->transport->request('POST', '/networks/prune', null, ['filters' => $filters === [] ? null : $filters]); } } diff --git a/src/Resources/System.php b/src/Resources/System.php index 56407a8..34a17bb 100644 --- a/src/Resources/System.php +++ b/src/Resources/System.php @@ -33,36 +33,25 @@ public function df(): DockerResponse } /** - * Streams Docker's real-time event feed (container/image/network/ - * volume lifecycle events). This call blocks until the connection - * ends or $onEvent returns false — pass a generous $timeout to - * DockerClient (or expect it to run until the process is killed). + * Streams Docker's real-time event feed (container/image/network/volume lifecycle events). + * This call blocks until the connection ends or $onEvent returns false - pass a generous $timeout to DockerClient (or expect it to run until the process is killed). * * @param array> $filters - * @param callable(array): (bool|void) $onEvent + * @param callable(array): (bool|void) $onEvent */ public function events(callable $onEvent, array $filters = [], ?string $since = null, ?string $until = null): void { $lineBuffer = new NdjsonLineBuffer(); - - $this->transport->stream('GET', '/events', null, [ - 'filters' => $filters === [] ? null : $filters, - 'since' => $since, - 'until' => $until, - ], static fn (string $chunk): bool => $lineBuffer->push($chunk, $onEvent)); + $this->transport->stream('GET', '/events', null, ['filters' => $filters === [] ? null : $filters, 'since' => $since, 'until' => $until], static fn(string $chunk): bool => $lineBuffer->push($chunk, $onEvent)); } /** - * Prunes containers, images, networks and the build cache in one - * call. Note this does not include volumes — use Volumes::prune() - * for those, matching the Engine API's own separation. + * Prunes containers, images, networks and the build cache in one call. Note this does not include volumes - use Volumes::prune() for those, matching the Engine API's own separation. * * @param array> $filters */ public function prune(array $filters = []): DockerResponse { - return $this->transport->request('POST', '/system/prune', null, [ - 'filters' => $filters === [] ? null : $filters, - ]); + return $this->transport->request('POST', '/system/prune', null, ['filters' => $filters === [] ? null : $filters]); } } diff --git a/src/Resources/Volumes.php b/src/Resources/Volumes.php index 3f6e924..9840476 100644 --- a/src/Resources/Volumes.php +++ b/src/Resources/Volumes.php @@ -19,24 +19,13 @@ final class Volumes extends AbstractResource */ public function list(array $filters = []): array { - $response = $this->transport->request('GET', '/volumes', null, [ - 'filters' => $filters === [] ? null : $filters, - ]); - - $data = $this->decodeObject($response); - $volumes = is_array($data['Volumes'] ?? null) ? $data['Volumes'] : []; - - return array_map( - static fn (mixed $item): VolumeInfo => VolumeInfo::fromArray(is_array($item) ? $item : []), - array_values($volumes), - ); + $data = $this->decodeObject($this->transport->request('GET', '/volumes', null, ['filters' => $filters === [] ? null : $filters])); + return array_map(static fn(mixed $item): VolumeInfo => VolumeInfo::fromArray(is_array($item) ? $item : []), array_values(is_array($data['Volumes'] ?? null) ? $data['Volumes'] : []), ); } public function inspect(string $name): VolumeInfo { - $response = $this->transport->request('GET', "/volumes/{$name}"); - - return VolumeInfo::fromArray($this->decodeObject($response)); + return VolumeInfo::fromArray($this->decodeObject($this->transport->request('GET', "/volumes/{$name}"))); } /** @@ -44,16 +33,12 @@ public function inspect(string $name): VolumeInfo */ public function create(string $name, array $options = []): DockerResponse { - $body = ['Name' => $name] + $options; - - return $this->transport->request('POST', '/volumes/create', $body); + return $this->transport->request('POST', '/volumes/create', ['Name' => $name] + $options); } public function remove(string $name, bool $force = false): DockerResponse { - return $this->transport->request('DELETE', "/volumes/{$name}", null, [ - 'force' => $force, - ]); + return $this->transport->request('DELETE', "/volumes/{$name}", null, ['force' => $force]); } /** @@ -61,8 +46,6 @@ public function remove(string $name, bool $force = false): DockerResponse */ public function prune(array $filters = []): DockerResponse { - return $this->transport->request('POST', '/volumes/prune', null, [ - 'filters' => $filters === [] ? null : $filters, - ]); + return $this->transport->request('POST', '/volumes/prune', null, ['filters' => $filters === [] ? null : $filters]); } } diff --git a/src/Support/NdjsonLineBuffer.php b/src/Support/NdjsonLineBuffer.php index 256e4b7..921047b 100644 --- a/src/Support/NdjsonLineBuffer.php +++ b/src/Support/NdjsonLineBuffer.php @@ -4,6 +4,8 @@ namespace Sytxlabs\Dockphp\Support; +use JsonException; + /** * Buffers streamed chunks and decodes newline-delimited JSON lines as * they become complete, since chunk boundaries from cURL never align @@ -20,7 +22,7 @@ final class NdjsonLineBuffer * non-JSON or non-object lines are skipped. Stops early and returns * false as soon as `$onEvent` itself returns false. * - * @param callable(array): (bool|void) $onEvent + * @param callable(array): (bool|void) $onEvent */ public function push(string $chunk, callable $onEvent): bool { @@ -29,17 +31,17 @@ public function push(string $chunk, callable $onEvent): bool while (($pos = strpos($this->buffer, "\n")) !== false) { $line = trim(substr($this->buffer, 0, $pos)); $this->buffer = substr($this->buffer, $pos + 1); - if ($line === '') { continue; } - - $decoded = json_decode($line, true); - + try { + $decoded = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + continue; + } if (!is_array($decoded)) { continue; } - if ($onEvent($decoded) === false) { return false; } diff --git a/src/Support/StdioDemultiplexer.php b/src/Support/StdioDemultiplexer.php index e4f928b..f2a3760 100644 --- a/src/Support/StdioDemultiplexer.php +++ b/src/Support/StdioDemultiplexer.php @@ -5,13 +5,10 @@ namespace Sytxlabs\Dockphp\Support; /** - * Splits Docker's multiplexed stdout/stderr stream format into - * individual frames. Applies only to containers created without a - * TTY (`Tty: false`) — with a TTY, Docker sends raw bytes and no - * demuxing is needed or possible. + * Splits Docker's multiplexed stdout/stderr stream format intoindividual frames. + * Applies only to containers created without a TTY (`Tty: false`) with a TTY, Docker sends raw bytes and no demuxing is needed or possible. * - * Frame format: 1 byte stream type (0 = stdin, 1 = stdout, 2 = stderr), - * 3 reserved bytes, 4-byte big-endian payload length, then the payload. + * Frame format: 1 byte stream type (0 = stdin, 1 = stdout, 2 = stderr), 3 reserved bytes, 4-byte big-endian payload length, then the payload. * * @see https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerAttach */ @@ -31,18 +28,15 @@ public static function demuxAll(string $rawBody): array { $demultiplexer = new self(); $frames = []; - $demultiplexer->push($rawBody, static function (string $stream, string $payload) use (&$frames): void { $frames[] = ['stream' => $stream, 'payload' => $payload]; }); - return $frames; } /** - * Feeds a streamed chunk in; emits every complete frame found so - * far via `$onFrame(string $stream, string $payload)`. Incomplete - * trailing frames are kept buffered until the next call. + * Feeds a streamed chunk in; emits every complete frame found so far via `$onFrame(string $stream, string $payload)`. + * Incomplete trailing frames are kept buffered until the next call. * * @param callable(string, string): void $onFrame */ @@ -53,20 +47,15 @@ public function push(string $chunk, callable $onFrame): void while (strlen($this->buffer) >= self::HEADER_LENGTH) { $streamType = ord($this->buffer[0]); $lengthUnpacked = unpack('N', substr($this->buffer, 4, 4)); - if ($lengthUnpacked === false) { break; } - $length = $lengthUnpacked[1]; - if (strlen($this->buffer) < self::HEADER_LENGTH + $length) { break; } - $payload = substr($this->buffer, self::HEADER_LENGTH, $length); $this->buffer = substr($this->buffer, self::HEADER_LENGTH + $length); - $onFrame(self::STREAM_NAMES[$streamType] ?? 'unknown', $payload); } } diff --git a/tests/Integration/DockerIntegrationTest.php b/tests/Integration/DockerIntegrationTest.php index a9a4d19..7952048 100644 --- a/tests/Integration/DockerIntegrationTest.php +++ b/tests/Integration/DockerIntegrationTest.php @@ -9,11 +9,7 @@ use Sytxlabs\Dockphp\DTO\ContainerSummary; use Sytxlabs\Dockphp\DTO\ImageSummary; -/** - * Runs only against a real Docker Engine. Automatically skipped when - * /var/run/docker.sock does not exist (e.g. on a machine without - * Docker, or on Windows) — no environment variable required. - */ +/** Runs only against a real Docker Engine. Automatically skipped when /var/run/docker.sock does not exist (e.g. on a machine without Docker, or on Windows) - no environment variable required. */ final class DockerIntegrationTest extends TestCase { private const SOCKET_PATH = '/var/run/docker.sock'; @@ -27,15 +23,12 @@ protected function setUp(): void public function testPingSucceedsAgainstRealDaemon(): void { - $client = new DockerClient(self::SOCKET_PATH); - - self::assertTrue($client->system()->ping()); + self::assertTrue((new DockerClient(self::SOCKET_PATH))->system()->ping()); } public function testListContainersAndImagesAgainstRealDaemon(): void { $client = new DockerClient(self::SOCKET_PATH); - $containers = $client->containers()->list(['all' => true]); self::assertIsArray($containers); if ($containers !== []) { @@ -51,8 +44,6 @@ public function testListContainersAndImagesAgainstRealDaemon(): void public function testApiVersionIsAutoDetected(): void { - $client = new DockerClient(self::SOCKET_PATH); - - self::assertMatchesRegularExpression('/^\d+\.\d+$/', $client->getApiVersion()); + self::assertMatchesRegularExpression('/^\d+\.\d+$/', (new DockerClient(self::SOCKET_PATH))->getApiVersion()); } } diff --git a/tests/Support/FakeDockerTransport.php b/tests/Support/FakeDockerTransport.php index 94e8743..6930ff2 100644 --- a/tests/Support/FakeDockerTransport.php +++ b/tests/Support/FakeDockerTransport.php @@ -9,47 +9,28 @@ use Sytxlabs\Dockphp\Http\DockerTransportInterface; /** - * In-memory fake used to unit-test Resources without a real Docker - * socket. Records every call and returns pre-configured responses; - * stream()/streamRaw() replay a canned list of chunks through the - * caller's $onChunk callback. + * In-memory fake used to unit-test Resources without a real Docker socket. + * Records every call and returns pre-configured responses; stream()/streamRaw() replay a canned list of chunks through the caller's $onChunk callback. */ final class FakeDockerTransport implements DockerTransportInterface { - /** - * @var list|string|null, - * query: array, - * headers: array, - * }> - */ + /** @var list|string|null, query: array, headers: array}> */ public array $calls = []; - /** @var list */ private array $streamChunks = []; - private int $streamStatus = 200; - public function __construct( - private DockerResponse $response = new DockerResponse(200, '{}'), - private string $apiVersion = '1.43', - ) { - } + public function __construct(private DockerResponse $response = new DockerResponse(200, '{}'), private string $apiVersion = '1.43') {} public function request(string $method, string $path, ?array $body = null, array $query = [], array $extraHeaders = []): DockerResponse { $this->record('request', $method, $path, $body, $query, $extraHeaders); - return $this->response; } public function requestRaw(string $method, string $path, ?string $rawBody, array $query = [], array $headers = []): DockerResponse { $this->record('requestRaw', $method, $path, $rawBody, $query, $headers); - return $this->response; } @@ -79,33 +60,20 @@ public function setResponse(DockerResponse $response): void $this->response = $response; } - /** - * @param list $chunks - */ + /** @param list $chunks */ public function setStreamChunks(array $chunks, int $status = 200): void { $this->streamChunks = $chunks; $this->streamStatus = $status; } - /** - * @return array{ - * kind: string, - * method: string, - * path: string, - * body: array|string|null, - * query: array, - * headers: array, - * } - */ + /** @return array{kind: string, method: string, path: string, body: array|string|null, query: array, headers: array} */ public function lastCall(): array { $call = end($this->calls); - if ($call === false) { throw new LogicException('No request was made.'); } - return $call; } @@ -116,14 +84,7 @@ public function lastCall(): array */ private function record(string $kind, string $method, string $path, array|string|null $body, array $query, array $headers = []): void { - $this->calls[] = [ - 'kind' => $kind, - 'method' => $method, - 'path' => $path, - 'body' => $body, - 'query' => $query, - 'headers' => $headers, - ]; + $this->calls[] = ['kind' => $kind, 'method' => $method, 'path' => $path, 'body' => $body, 'query' => $query, 'headers' => $headers]; } private function replayChunks(callable $onChunk): void diff --git a/tests/Support/TestableDockerTransport.php b/tests/Support/TestableDockerTransport.php index 520bcaa..f60e3c9 100644 --- a/tests/Support/TestableDockerTransport.php +++ b/tests/Support/TestableDockerTransport.php @@ -7,8 +7,7 @@ use Sytxlabs\Dockphp\Http\DockerTransport; /** - * Exposes DockerTransport's protected pure helper methods for unit - * testing without touching curl or a real socket. + * Exposes DockerTransport's protected pure helper methods for unit testing without touching curl or a real socket. */ final class TestableDockerTransport extends DockerTransport { diff --git a/tests/Unit/DTO/ContainerInfoTest.php b/tests/Unit/DTO/ContainerInfoTest.php index cda56ef..a9c7d93 100644 --- a/tests/Unit/DTO/ContainerInfoTest.php +++ b/tests/Unit/DTO/ContainerInfoTest.php @@ -34,27 +34,19 @@ public function testFromArrayHydratesKnownFields(): void public function testGetNameStripsLeadingSlash(): void { - $info = ContainerInfo::fromArray(['Name' => '/web']); - - self::assertSame('web', $info->getName()); + self::assertSame('web', ContainerInfo::fromArray(['Name' => '/web'])->getName()); } public function testIsRunningReflectsStateRunning(): void { - $running = ContainerInfo::fromArray(['State' => ['Running' => true]]); - $stopped = ContainerInfo::fromArray(['State' => ['Running' => false]]); - $missing = ContainerInfo::fromArray([]); - - self::assertTrue($running->isRunning()); - self::assertFalse($stopped->isRunning()); - self::assertFalse($missing->isRunning()); + self::assertTrue(ContainerInfo::fromArray(['State' => ['Running' => true]])->isRunning()); + self::assertFalse(ContainerInfo::fromArray(['State' => ['Running' => false]])->isRunning()); + self::assertFalse(ContainerInfo::fromArray([])->isRunning()); } public function testRawReturnsOriginalArrayUntouched(): void { $data = ['Id' => 'abc123', 'SomeUnmodeledField' => 'kept']; - $info = ContainerInfo::fromArray($data); - - self::assertSame($data, $info->raw()); + self::assertSame($data, ContainerInfo::fromArray($data)->raw()); } } diff --git a/tests/Unit/DTO/ContainerSummaryTest.php b/tests/Unit/DTO/ContainerSummaryTest.php index b2ac360..2797e46 100644 --- a/tests/Unit/DTO/ContainerSummaryTest.php +++ b/tests/Unit/DTO/ContainerSummaryTest.php @@ -35,23 +35,17 @@ public function testFromArrayHydratesKnownFields(): void public function testGetNameStripsLeadingSlash(): void { - $summary = ContainerSummary::fromArray(['Names' => ['/web']]); - - self::assertSame('web', $summary->getName()); + self::assertSame('web', ContainerSummary::fromArray(['Names' => ['/web']])->getName()); } public function testGetNameReturnsEmptyStringWhenNoNames(): void { - $summary = ContainerSummary::fromArray([]); - - self::assertSame('', $summary->getName()); + self::assertSame('', ContainerSummary::fromArray([])->getName()); } public function testRawReturnsOriginalArrayUntouched(): void { $data = ['Id' => 'abc123', 'SomeUnmodeledField' => 'kept']; - $summary = ContainerSummary::fromArray($data); - - self::assertSame($data, $summary->raw()); + self::assertSame($data, ContainerSummary::fromArray($data)->raw()); } } diff --git a/tests/Unit/DTO/ImageInfoTest.php b/tests/Unit/DTO/ImageInfoTest.php index d221545..d5349b9 100644 --- a/tests/Unit/DTO/ImageInfoTest.php +++ b/tests/Unit/DTO/ImageInfoTest.php @@ -42,8 +42,6 @@ public function testGetNameReturnsFirstRepoTagOrNull(): void public function testRawReturnsOriginalArrayUntouched(): void { $data = ['Id' => 'sha256:abc123', 'SomeUnmodeledField' => 'kept']; - $info = ImageInfo::fromArray($data); - - self::assertSame($data, $info->raw()); + self::assertSame($data, ImageInfo::fromArray($data)->raw()); } } diff --git a/tests/Unit/DTO/ImageSummaryTest.php b/tests/Unit/DTO/ImageSummaryTest.php index 2c253ad..5c04e83 100644 --- a/tests/Unit/DTO/ImageSummaryTest.php +++ b/tests/Unit/DTO/ImageSummaryTest.php @@ -32,23 +32,17 @@ public function testFromArrayHydratesKnownFields(): void public function testGetNameReturnsFirstRepoTag(): void { - $summary = ImageSummary::fromArray(['RepoTags' => ['nginx:latest', 'nginx:1.25']]); - - self::assertSame('nginx:latest', $summary->getName()); + self::assertSame('nginx:latest', ImageSummary::fromArray(['RepoTags' => ['nginx:latest', 'nginx:1.25']])->getName()); } public function testGetNameReturnsNullForUntaggedImage(): void { - $summary = ImageSummary::fromArray(['RepoTags' => []]); - - self::assertNull($summary->getName()); + self::assertNull(ImageSummary::fromArray(['RepoTags' => []])->getName()); } public function testRawReturnsOriginalArrayUntouched(): void { $data = ['Id' => 'sha256:abc123', 'SomeUnmodeledField' => 'kept']; - $summary = ImageSummary::fromArray($data); - - self::assertSame($data, $summary->raw()); + self::assertSame($data, ImageSummary::fromArray($data)->raw()); } } diff --git a/tests/Unit/DTO/NetworkInfoTest.php b/tests/Unit/DTO/NetworkInfoTest.php index 0eb1ed5..8d70969 100644 --- a/tests/Unit/DTO/NetworkInfoTest.php +++ b/tests/Unit/DTO/NetworkInfoTest.php @@ -34,16 +34,12 @@ public function testFromArrayHydratesKnownFields(): void public function testGetNameReturnsName(): void { - $network = NetworkInfo::fromArray(['Name' => 'my-network']); - - self::assertSame('my-network', $network->getName()); + self::assertSame('my-network', NetworkInfo::fromArray(['Name' => 'my-network'])->getName()); } public function testRawReturnsOriginalArrayUntouched(): void { $data = ['Id' => 'net123', 'SomeUnmodeledField' => 'kept']; - $network = NetworkInfo::fromArray($data); - - self::assertSame($data, $network->raw()); + self::assertSame($data, NetworkInfo::fromArray($data)->raw()); } } diff --git a/tests/Unit/DTO/VolumeInfoTest.php b/tests/Unit/DTO/VolumeInfoTest.php index de35886..4a0e9e8 100644 --- a/tests/Unit/DTO/VolumeInfoTest.php +++ b/tests/Unit/DTO/VolumeInfoTest.php @@ -20,7 +20,6 @@ public function testFromArrayHydratesKnownFields(): void 'Labels' => ['env' => 'prod'], 'Options' => [], ]); - self::assertSame('my-volume', $volume->name); self::assertSame('local', $volume->driver); self::assertSame('/var/lib/docker/volumes/my-volume/_data', $volume->mountpoint); @@ -31,16 +30,12 @@ public function testFromArrayHydratesKnownFields(): void public function testGetNameReturnsName(): void { - $volume = VolumeInfo::fromArray(['Name' => 'my-volume']); - - self::assertSame('my-volume', $volume->getName()); + self::assertSame('my-volume', VolumeInfo::fromArray(['Name' => 'my-volume'])->getName()); } public function testRawReturnsOriginalArrayUntouched(): void { $data = ['Name' => 'my-volume', 'SomeUnmodeledField' => 'kept']; - $volume = VolumeInfo::fromArray($data); - - self::assertSame($data, $volume->raw()); + self::assertSame($data, VolumeInfo::fromArray($data)->raw()); } } diff --git a/tests/Unit/DockerClientTest.php b/tests/Unit/DockerClientTest.php index a5a251a..9208ccc 100644 --- a/tests/Unit/DockerClientTest.php +++ b/tests/Unit/DockerClientTest.php @@ -41,7 +41,6 @@ public function testResourcesAreMemoized(): void public function testGetApiVersionReturnsManualOverrideWithoutIo(): void { $client = new DockerClient('/var/run/docker.sock', '1.43'); - self::assertSame('1.43', $client->getApiVersion()); } } diff --git a/tests/Unit/Exceptions/DockerApiExceptionTest.php b/tests/Unit/Exceptions/DockerApiExceptionTest.php index 8a4dfeb..7d6723e 100644 --- a/tests/Unit/Exceptions/DockerApiExceptionTest.php +++ b/tests/Unit/Exceptions/DockerApiExceptionTest.php @@ -12,13 +12,7 @@ final class DockerApiExceptionTest extends TestCase { public function testFromResponseExtractsDockerMessage(): void { - $exception = DockerApiException::fromResponse( - 'GET', - '/containers/abc/json', - 500, - '{"message":"something went wrong"}', - ); - + $exception = DockerApiException::fromResponse('GET', '/containers/abc/json', 500, '{"message":"something went wrong"}'); self::assertSame(500, $exception->getStatusCode()); self::assertSame('something went wrong', $exception->getDockerMessage()); self::assertStringContainsString('something went wrong', $exception->getMessage()); @@ -28,20 +22,13 @@ public function testFromResponseExtractsDockerMessage(): void public function testFromResponseHandlesNonJsonBody(): void { $exception = DockerApiException::fromResponse('GET', '/info', 502, 'Bad Gateway'); - self::assertNull($exception->getDockerMessage()); self::assertSame('Bad Gateway', $exception->getResponseBody()); } public function testFromResponseOnNotFoundSubclassPreservesType(): void { - $exception = DockerNotFoundException::fromResponse( - 'GET', - '/containers/missing/json', - 404, - '{"message":"No such container: missing"}', - ); - + $exception = DockerNotFoundException::fromResponse('GET', '/containers/missing/json', 404, '{"message":"No such container: missing"}'); self::assertInstanceOf(DockerNotFoundException::class, $exception); self::assertSame(404, $exception->getStatusCode()); self::assertSame('No such container: missing', $exception->getDockerMessage()); diff --git a/tests/Unit/Exceptions/DockerConnectionExceptionTest.php b/tests/Unit/Exceptions/DockerConnectionExceptionTest.php index a223d85..ff691cd 100644 --- a/tests/Unit/Exceptions/DockerConnectionExceptionTest.php +++ b/tests/Unit/Exceptions/DockerConnectionExceptionTest.php @@ -11,12 +11,7 @@ final class DockerConnectionExceptionTest extends TestCase { public function testFromCurlErrorBuildsReadableMessage(): void { - $exception = DockerConnectionException::fromCurlError( - '/var/run/docker.sock', - 'Couldn\'t connect to server', - 7, - ); - + $exception = DockerConnectionException::fromCurlError('/var/run/docker.sock', 'Couldn\'t connect to server', 7); self::assertSame(7, $exception->getCode()); self::assertStringContainsString('/var/run/docker.sock', $exception->getMessage()); self::assertStringContainsString("Couldn't connect to server", $exception->getMessage()); diff --git a/tests/Unit/Http/DockerResponseTest.php b/tests/Unit/Http/DockerResponseTest.php index 1493f5f..ccc4a57 100644 --- a/tests/Unit/Http/DockerResponseTest.php +++ b/tests/Unit/Http/DockerResponseTest.php @@ -19,9 +19,7 @@ public function testIsSuccessfulForTwoXxStatus(): void public function testJsonDecodesValidBody(): void { - $response = new DockerResponse(200, '{"Id":"abc123","Warnings":[]}'); - - self::assertSame(['Id' => 'abc123', 'Warnings' => []], $response->json()); + self::assertSame(['Id' => 'abc123', 'Warnings' => []], (new DockerResponse(200, '{"Id":"abc123","Warnings":[]}'))->json()); } public function testJsonReturnsNullForEmptyBody(): void @@ -38,14 +36,12 @@ public function testJsonReturnsNullForInvalidJson(): void public function testJsonIsMemoized(): void { $response = new DockerResponse(200, '{"a":1}'); - self::assertSame($response->json(), $response->json()); } public function testGetBodyReturnsRawString(): void { $response = new DockerResponse(200, '{"a":1}'); - self::assertSame('{"a":1}', $response->getBody()); } } diff --git a/tests/Unit/Http/DockerTransportTcpTest.php b/tests/Unit/Http/DockerTransportTcpTest.php index cd31020..9244251 100644 --- a/tests/Unit/Http/DockerTransportTcpTest.php +++ b/tests/Unit/Http/DockerTransportTcpTest.php @@ -11,41 +11,25 @@ final class DockerTransportTcpTest extends TestCase { public function testBuildUrlUsesHttpForPlainTcp(): void { - $transport = TestableDockerTransport::forTcp('docker.example.com', 2375, false, apiVersion: '1.43'); - - self::assertSame( - 'http://docker.example.com:2375/containers/json', - $transport->publicBuildUrl('/containers/json', []), - ); + $transport = TestableDockerTransport::forTcp('docker.example.com', apiVersion: '1.43'); + self::assertSame('http://docker.example.com:2375/containers/json', $transport->publicBuildUrl('/containers/json', [])); } public function testBuildUrlUsesHttpsWhenTlsEnabled(): void { $transport = TestableDockerTransport::forTcp('docker.example.com', 2376, true, apiVersion: '1.43'); - - self::assertSame( - 'https://docker.example.com:2376/containers/json', - $transport->publicBuildUrl('/containers/json', []), - ); + self::assertSame('https://docker.example.com:2376/containers/json', $transport->publicBuildUrl('/containers/json', []), ); } public function testBuildUrlUsesCustomPort(): void { $transport = TestableDockerTransport::forTcp('10.0.0.5', 9999, apiVersion: '1.43'); - - self::assertSame( - 'http://10.0.0.5:9999/info', - $transport->publicBuildUrl('/info', []), - ); + self::assertSame('http://10.0.0.5:9999/info', $transport->publicBuildUrl('/info', [])); } public function testForSocketStillUsesLocalhostBase(): void { $transport = TestableDockerTransport::forSocket('/var/run/docker.sock', '1.43'); - - self::assertSame( - 'http://localhost/info', - $transport->publicBuildUrl('/info', []), - ); + self::assertSame('http://localhost/info', $transport->publicBuildUrl('/info', [])); } } diff --git a/tests/Unit/Http/DockerTransportTest.php b/tests/Unit/Http/DockerTransportTest.php index cc2ee44..e9e78ec 100644 --- a/tests/Unit/Http/DockerTransportTest.php +++ b/tests/Unit/Http/DockerTransportTest.php @@ -18,53 +18,42 @@ protected function setUp(): void public function testBuildUrlWithoutQuery(): void { - self::assertSame( - 'http://localhost/containers/json', - $this->transport->publicBuildUrl('/containers/json', []), - ); + self::assertSame('http://localhost/containers/json', $this->transport->publicBuildUrl('/containers/json', [])); } public function testBuildUrlWithScalarQuery(): void { $url = $this->transport->publicBuildUrl('/containers/abc/json', ['size' => true]); - self::assertSame('http://localhost/containers/abc/json?size=true', $url); } public function testBuildQueryStringDropsNullValues(): void { $query = $this->transport->publicBuildQueryString(['t' => null, 'signal' => 'SIGTERM']); - self::assertSame('signal=SIGTERM', $query); } public function testBuildQueryStringEncodesBooleans(): void { $query = $this->transport->publicBuildQueryString(['force' => true, 'noprune' => false]); - self::assertSame('force=true&noprune=false', $query); } public function testBuildQueryStringJsonEncodesArrayValues(): void { - $query = $this->transport->publicBuildQueryString([ - 'filters' => ['status' => ['running']], - ]); - + $query = $this->transport->publicBuildQueryString(['filters' => ['status' => ['running']]]); self::assertSame('filters=' . rawurlencode('{"status":["running"]}'), $query); } public function testBuildQueryStringUrlEncodesValues(): void { $query = $this->transport->publicBuildQueryString(['name' => 'my container']); - self::assertSame('name=my%20container', $query); } public function testBuildUrlCombinesPathAndMultipleQueryParams(): void { $url = $this->transport->publicBuildUrl('/containers/json', ['all' => true, 'limit' => 5]); - self::assertSame('http://localhost/containers/json?all=true&limit=5', $url); } @@ -75,9 +64,6 @@ public function testGetApiVersionReturnsManuallyConfiguredOverride(): void public function testEncodeJsonEncodesEmptyArrayAsObjectNotList(): void { - // Regression: Docker's Go structs (e.g. ExecStartOptions) reject an - // empty JSON array `[]` where an object `{}` is expected - PHP's - // empty array is otherwise indistinguishable from an empty list. self::assertSame('{}', $this->transport->publicEncodeJson([])); } diff --git a/tests/Unit/Resources/ContainersTest.php b/tests/Unit/Resources/ContainersTest.php index 715f2aa..d28dd3c 100644 --- a/tests/Unit/Resources/ContainersTest.php +++ b/tests/Unit/Resources/ContainersTest.php @@ -276,8 +276,7 @@ public function testStatsStreamRequestsContinuousStream(): void { $this->transport->setStreamChunks(['{"cpu":1}']); - $this->containers->statsStream('abc123', function (): void { - }); + $this->containers->statsStream('abc123', function (): void {}); $call = $this->transport->lastCall(); @@ -287,8 +286,7 @@ public function testStatsStreamRequestsContinuousStream(): void public function testAttachStreamDefaultsToStdoutStderrStream(): void { - $this->containers->attachStream('abc123', function (): void { - }); + $this->containers->attachStream('abc123', function (): void {}); $call = $this->transport->lastCall();