Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021-2026 AppsDevTeam

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
216 changes: 215 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,215 @@
# cockpit
# ADT Cockpit

A small PHP client for the [Cockpit CMS](https://getcockpit.com/) content API. It fetches
collections and singletons over HTTP (Guzzle), wraps entries in a convenient, lazily
evaluated `Entry` object, and supports per-field callbacks (e.g. for localization).

## Installation

```
composer require adt/cockpit
```

Requires PHP >= 8.0 and `guzzlehttp/guzzle ^7.0`.

## Configuration

Register the service in your Nette DI config (`config.neon`):

```neon
services:
cockpit:
class: ADT\Cockpit\Cockpit
setup:
- setParameters(%cockpit.url%, %cockpit.apiKey%, %cockpit.host%)
# - setHttpOptions(3, 2.0, 30.0, 250) # optional, see "HTTP resilience"
# - setOnGetEntryOffset([[@App\Model\CockpitFilters, localizeValue]]) # optional, see "Callbacks"
```

```neon
parameters:
cockpit:
url: https://cockpit.example.com
apiKey: your-cockpit-api-token
host: null
```

### `setParameters(string $url, string $apiKey, ?string $host = null)`

| Argument | Description |
|----------|-------------|
| `$url` | Base URL of the Cockpit instance. `/api` is appended automatically. |
| `$apiKey` | Cockpit API token, sent as the `Cockpit-Token` header. |
| `$host` | Optional `Host` header override. When omitted, it is derived from `$url`. |

The `$host` override exists for setups where Cockpit is reached over an **internal address**
(e.g. a Docker service name) while it must still receive a public host name for routing:

```neon
parameters:
cockpit:
url: http://nginx # internal, reached over the container network
host: cockpit.example.com # sent as the Host header so the backend routes correctly
```

## Usage

Once configured, inject the service and query your content.

```php
use ADT\Cockpit\Cockpit;

public function __construct(private Cockpit $cockpit) {}
```

### Collections

```php
// All entries of a collection, optionally filtered and sorted.
// getEntries(string $collection, array $filter = [], array $sort = [], int $limit = -1): Entry[]
$pages = $this->cockpit->getEntries('sites', [], ['position' => 'ASC']);

foreach ($pages as $page) {
echo $page['internalKey'];
}

// A single entry (the first match, fetched with limit 1), or null.
// getEntry(string $collection, array $filter = [], array $sort = []): ?Entry
$page = $this->cockpit->getEntry('sites', ['slug' => 'contact']);
```

`$filter` and `$sort` are passed through to Cockpit as its `filter` and `sort` query
parameters; `$limit` defaults to `-1` (no limit). Entries are returned already populated
(`populate = 1`).

### Singletons

```php
// getSingleton(string $singleton): array
$footer = $this->cockpit->getSingleton('Footer');
echo $footer['address'];
```

### The `Entry` object

`getEntries()` / `getEntry()` return `Entry` objects (singletons are returned as plain
arrays). `Entry` implements `ArrayAccess`, `Countable` and `IteratorAggregate`, so you can
use it like an array:

```php
$page['title']; // read a field
isset($page['subtitle']); // check a field
count($page); // number of fields
foreach ($page as $key => $val) // iterate

$page->toArray(); // the raw underlying array (nested Entry objects are unwrapped)
```

Nested arrays are wrapped in `Entry` objects too, so deep access works the same way. The
exception is file/asset fields (arrays containing `path` and `mime`) — those intentionally
stay plain arrays, ready to be passed to `getAssetPath()`.

Evaluation is **lazy**: the registered callbacks (see below) run on first access — any
field read, `isset()`, `count()` or iteration — not when the entry is fetched.

### Assets

`getAssetPath()` builds the public path (under `/storage/uploads`) for a Cockpit asset,
optionally for a specific image size:

```php
use ADT\Cockpit\Cockpit;

Cockpit::getAssetPath($entry['image']); // original
Cockpit::getAssetPath($entry['image'], 'thumb'); // a named size
```

## HTTP resilience

Cockpit content is typically fetched over HTTP on **every request**, so a brief
unavailability of the backend — for example an nginx/Cockpit container being recreated
during a deploy or image update — would otherwise surface directly as a request error
(e.g. *cURL error 7: Failed to connect*) and, in turn, as an error page for the visitor.

The client therefore uses a Guzzle retry middleware and sensible timeouts by default:

- **Retries** on connection-level failures (`ConnectException`: connection refused, DNS
errors, connect timeouts) and on transient **5xx** responses. `4xx` responses are **not**
retried.
- **Exponential backoff** between attempts.
- **Connect and overall timeouts** so a stuck backend fails fast instead of hanging.

Defaults are applied automatically — no configuration required. To tune them, call
`setHttpOptions()` before the first request (e.g. from DI setup):

```neon
services:
cockpit:
class: ADT\Cockpit\Cockpit
setup:
- setParameters(%cockpit.url%, %cockpit.apiKey%, %cockpit.host%)
- setHttpOptions(3, 2.0, 5.0, 250) # e.g. a tighter timeout for a latency-sensitive site
```

### `setHttpOptions(int $maxRetries = 3, float $connectTimeout = 2.0, float $timeout = 30.0, int $retryBaseDelayMs = 250)`

| Argument | Default | Description |
|---------------------|---------|-------------|
| `$maxRetries` | `3` | Number of retries on connection failures / 5xx responses (4 attempts total). |
| `$connectTimeout` | `2.0` | Seconds to wait for the connection to be established. |
| `$timeout` | `30.0` | Seconds to wait for the whole request to finish. `0` disables the limit. |
| `$retryBaseDelayMs` | `250` | Base backoff in milliseconds; grows exponentially (250, 500, 1000, …). |

With the defaults, fast connection failures (connection refused while a container is
being recreated) are absorbed transparently, adding at most ~1.75 s of backoff before the
request finally fails. When the failure is a timeout, each attempt itself may additionally
take up to `$connectTimeout` (or `$timeout`) seconds.

### Choosing a timeout

An exceeded **total timeout** surfaces as a connection error (cURL error 28) and is
therefore **retried** as well — a request that genuinely needs longer than `$timeout`
gets attempted `$maxRetries + 1` times before failing, multiplying the load on the
backend. Keep `$timeout` comfortably above your slowest legitimate response:

- **Latency-sensitive web pages** may prefer a tighter timeout, e.g.
`setHttpOptions(3, 2.0, 5.0)`, so a stuck backend fails fast.
- **Console commands / batch reads** of large collections (thousands of populated
entries in a single `getEntries()` call) should raise the limit, or disable it
entirely with `setHttpOptions(3, 2.0, 0)` — that matches the pre-1.3 behavior of
no timeout at all.

### Errors

Fetch methods throw `GuzzleHttp\Exception\GuzzleException` when the request ultimately
fails (including 4xx/5xx responses, after any retries) and `JsonException` when the
response body is not valid JSON.

## Callbacks

Two callback hooks let you post-process entry data. Each callback receives the entry's
values **by reference**, so it can mutate them in place. They apply to `Entry` objects
(collections), not to singletons.

### `setOnGetEntryOffset(array $callbacks)`

Called every time a field is read. The callback signature is
`function (array &$values, string|int $offset)`. This is the typical place for
**localization** — resolving a locale-specific field on access:

```neon
services:
cockpit:
setup:
- setOnGetEntryOffset([[@App\Model\CockpitFilters, localizeValue]])
```

### `setOnLoadEntry(array $callbacks)`

Called once, the first time any field of an entry is accessed. The callback signature is
`function (array &$values)`. Use it for one-off preparation of an entry before its fields
are read.

## License

MIT — see [LICENSE](LICENSE).
2 changes: 2 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"name": "adt/cockpit",
"description": "PHP client for the Cockpit CMS content API",
"type": "library",
"license": "MIT",
"autoload": {
"psr-4": {
"ADT\\Cockpit\\": "src/"
Expand Down
87 changes: 81 additions & 6 deletions src/Cockpit.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
namespace ADT\Cockpit;

use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\ResponseInterface;

class Cockpit
{
Expand All @@ -18,6 +23,20 @@ class Cockpit

protected array $onGetEntryOffset = [];

protected ?Client $client = null;

/** Number of retries for connection failures and 5xx responses. */
protected int $maxRetries = 3;

/** Seconds to wait for the connection to be established. */
protected float $connectTimeout = 2.0;

/** Seconds to wait for the whole request to finish (0 = no limit). */
protected float $timeout = 30.0;

/** Base backoff between retries in milliseconds (grows exponentially). */
protected int $retryBaseDelayMs = 250;

public function setParameters(string $url, string $apiKey, ?string $host = null): void
{
$this->apiUrl = rtrim($url, '/') . '/api';
Expand All @@ -35,16 +54,67 @@ public function setOnGetEntryOffset(array $callbacks): void
$this->onGetEntryOffset = $callbacks;
}

/**
* Tune HTTP resilience. Call before the first request (e.g. from DI setup).
*/
public function setHttpOptions(int $maxRetries = 3, float $connectTimeout = 2.0, float $timeout = 30.0, int $retryBaseDelayMs = 250): void
{
$this->maxRetries = $maxRetries;
$this->connectTimeout = $connectTimeout;
$this->timeout = $timeout;
$this->retryBaseDelayMs = $retryBaseDelayMs;
$this->client = null; // force rebuild with the new options
}

protected function getClient(): Client
{
if ($this->client !== null) {
return $this->client;
}

$stack = HandlerStack::create();
$stack->push(Middleware::retry(
function (int $retries, $request, ?ResponseInterface $response = null, ?\Throwable $e = null): bool {
if ($retries >= $this->maxRetries) {
return false;
}

// Connection-level failures (e.g. cURL error 7 "couldn't connect",
// DNS errors, connect timeouts) — typically a dependency being restarted.
if ($e instanceof ConnectException) {
return true;
}

// Transient server errors (5xx), surfaced either as a response or an exception.
$status = $response?->getStatusCode();
if ($status === null && $e instanceof RequestException && $e->getResponse() !== null) {
$status = $e->getResponse()->getStatusCode();
}

return $status !== null && $status >= 500;
},
function (int $retries): int {
return $this->retryBaseDelayMs * (2 ** ($retries - 1)); // ms: 250, 500, 1000, ...
}
));

return $this->client = new Client([
'handler' => $stack,
'connect_timeout' => $this->connectTimeout,
'timeout' => $this->timeout,
]);
}

/**
* @param string $url
* @param array $data
* @return array
* @throws GuzzleException
* @throws \JsonException
*/
private function get(string $url, array $data = []): array
{
$client = new Client(['base_uri' => $this->apiUrl]);
$response = $client->request("GET", $url, [
$response = $this->getClient()->request("GET", $url, [
'headers' => [
'Content-Type' => 'application/json',
'Cockpit-Token' => $this->apiKey,
Expand All @@ -53,7 +123,7 @@ private function get(string $url, array $data = []): array
'body' => json_encode($data),
]);

return json_decode($response->getBody()->getContents(), true);
return json_decode($response->getBody()->getContents(), true, 512, JSON_THROW_ON_ERROR);
}

/**
Expand All @@ -78,7 +148,7 @@ public function getEntries(string $collection, array $filters = [], array $sorts
*/
public function getEntry(string $collection, array $filters = [], array $sorts = []): ?Entry
{
return $this->getEntries($collection, $filters, $sorts)[0] ?? null;
return $this->getEntries($collection, $filters, $sorts, 1)[0] ?? null;
}

/**
Expand All @@ -96,11 +166,16 @@ public static function getAssetPath(array|Entry $file, ?string $size = null): st
}

if (is_null($size)) {
return static::UPLOADS_DIR . '/' . ltrim($file['path'], static::UPLOADS_DIR);
$path = $file['path'];
if (str_starts_with($path, static::UPLOADS_DIR)) {
$path = substr($path, strlen(static::UPLOADS_DIR));
}

return static::UPLOADS_DIR . '/' . ltrim($path, '/');
}

if (isset($file['sizes'][$size])) {
return static::UPLOADS_DIR . $file['sizes'][$size]['path'];
return static::UPLOADS_DIR . '/' . ltrim($file['sizes'][$size]['path'], '/');
}

return static::UPLOADS_DIR . '/' . $size . '/' . array_reverse(explode('/', $file['path']))[0];
Expand Down
Loading