[READ-ONLY] Mirror of https://github.com/openstatusHQ/sdk-php. php sdk for openstatus www.openstatus.dev
sdk-php docs decisions.md
22 kB · 434 lines
Markdown
at main

Openstatus PHP SDK — Implementation Plan #

Build an official PHP SDK for the Openstatus API, mirroring the ergonomics of @openstatusHQ/sdk-node.

Approach: pull the pre-generated PHP message classes from Buf's CDN archive, then hand-write a thin HTTP+JSON transport on top. No Buf CLI, no OpenAPI generator, no protobuf binary wire format — just JSON over HTTP, type-safe via the generated message classes.

1. What the API actually looks like #

Confirmed by fetching https://api.openstatus.dev/openapi.yaml:

  • Server: https://api.openstatus.dev/
  • Auth: header x-openstatus-key: <token>
  • Path shape: POST /rpc/{package.Service}/{Method} with application/json body (Connect RPC's JSON mode).
  • Error envelope (Connect-style):
    { "code": "not_found", "message": "...", "details": [ ... ] }
    
    code is one of: canceled, unknown, invalid_argument, deadline_exceeded, not_found, already_exists, permission_denied, resource_exhausted, failed_precondition, aborted, out_of_range, unimplemented, internal, unavailable, data_loss, unauthenticated.
  • Services & methods (~52 RPCs total):
    • HealthService: Check
    • MonitorService: CreateHTTPMonitor, CreateTCPMonitor, CreateDNSMonitor, UpdateHTTPMonitor, UpdateTCPMonitor, UpdateDNSMonitor, GetMonitor, ListMonitors, TriggerMonitor, DeleteMonitor, GetMonitorStatus, GetMonitorSummary, ListMonitorHTTPResponseLogs, GetMonitorHTTPResponseLog
    • StatusReportService: CreateStatusReport, GetStatusReport, ListStatusReports, UpdateStatusReport, DeleteStatusReport, AddStatusReportUpdate
    • StatusPageService: CreateStatusPage, GetStatusPage, ListStatusPages, UpdateStatusPage, DeleteStatusPage, GetStatusPageContent, GetOverallStatus, AddMonitorComponent, AddStaticComponent, RemoveComponent, UpdateComponent, CreateComponentGroup, UpdateComponentGroup, DeleteComponentGroup, SubscribeToPage, UnsubscribeFromPage, CreatePageSubscription, ListSubscribers
    • MaintenanceService: CreateMaintenance, GetMaintenance, ListMaintenances, UpdateMaintenance, DeleteMaintenance
    • NotificationService: CreateNotification, GetNotification, ListNotifications, UpdateNotification, DeleteNotification, SendTestNotification, CheckNotificationLimit

HealthService.Check also accepts GET with a message query param; we only support POST to keep the transport uniform.

2. Foundational decisions #

Area Decision
PHP minimum 8.4 — property hooks, asymmetric visibility, readonly, native enums
HTTP client PSR-18 + PSR-17 factories, Guzzle shipped as default impl
Concurrency Sync-only for v0.1
Wire format JSON only (application/json)
Default timeout 30s, overridable per-client and per-call
Retries None — users' framework owns retry policy
User-Agent openstatus-php/<sdk-version> php/<php-version>
Strict types declare(strict_types=1); on every hand-written file (enforced via cs-fixer)
Visibility All hand-written public classes are final
Versioning 0.x = breaking changes allowed; commit to SemVer at 1.0

3. Codegen: pre-built Buf archive (no Buf CLI) #

Buf hosts a pre-generated zip of the protocolbuffers/php output for each pinned schema version. Verified:

curl -fsSL -O https://buf.build/gen/archive/openstatus/api/protocolbuffers/php/v35.0-7d7b7047611f.1.zip

What it contains (191 files):

  • api_php/Openstatus/**/*.php — message classes, PHP namespace Openstatus\…\V1\… (e.g. Openstatus\Monitor\V1\CreateHTTPMonitorRequest).
  • api_php/GPBMetadata/Openstatus/**/*.php — descriptor metadata loaded by initOnce() during message construction.
  • All classes extend \Google\Protobuf\Internal\Message, which provides serializeToJsonString() and mergeFromJsonString().
  • Enums are emitted as classes with const ints + name()/value() static helpers. Field getters return raw int.
  • int64 fields return int|string (string on 32-bit platforms). We document the quirk in README; no hand-written conversion layer.
  • No service stubs (protocolbuffers/php only emits messages) — we hand-write per-RPC dispatch.
  • No composer.json — we ship our own.

The archive references two transitive metadata initialisers it does not ship: GPBMetadata\Buf\Validate\Validate and GPBMetadata\Gnostic\Openapi\V3\Annotations. Both are pure descriptor metadata for field annotations; they carry no wire data. We ship empty initOnce() stubs at src/Stubs/GPBMetadataStubs.php so the runtime can initialise. (The upstream protocolbuffers/php plugin cannot compile buf.validate due to a closed enum, so even include_imports: true would not solve this.)

Regen script #

scripts/regen.sh — the archive version is pinned in the script. Bumping it is an explicit edit + PR (no CLI override), so every schema change shows up as a reviewable diff:

#!/usr/bin/env bash
set -euo pipefail

# Pinned Buf-generated PHP archive for buf.build/openstatus/api.
# To upgrade: change this line, run `composer regen`, commit the result.
readonly BUF_PHP_VERSION="v35.0-7d7b7047611f.1"

URL="https://buf.build/gen/archive/openstatus/api/protocolbuffers/php/${BUF_PHP_VERSION}.zip"

rm -rf src/Gen
mkdir -p src/Gen
curl -fsSL "$URL" -o /tmp/openstatus-buf.zip
unzip -q /tmp/openstatus-buf.zip -d /tmp/openstatus-buf
mv /tmp/openstatus-buf/api_php/Openstatus src/Gen/Openstatus
mv /tmp/openstatus-buf/api_php/GPBMetadata src/Gen/GPBMetadata
rm -rf /tmp/openstatus-buf /tmp/openstatus-buf.zip

echo "${BUF_PHP_VERSION}" > src/Gen/VERSION

src/Gen/VERSION is committed alongside the generated code so the current pin is always visible. src/Gen/ itself is also committed so end-users don't need to regen on install.

Upgrade workflow #

  1. Find the latest archive version on buf.build/openstatus/api/sdks/main:protocolbuffers/php.
  2. Edit BUF_PHP_VERSION in scripts/regen.sh.
  3. Run composer regen.
  4. Commit scripts/regen.sh + the resulting src/Gen/ diff in the same PR.
  5. The weekly regen.yml workflow (see §11) does steps 2–4 automatically when a new pinned version is published.

4. Repository layout #

php-sdk/
├── docs/decisions.md                    # this file
├── composer.json
├── phpunit.xml.dist
├── phpstan.neon
├── .php-cs-fixer.dist.php
├── .editorconfig
├── README.md
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE                              # MIT
├── scripts/
│   └── regen.sh
├── examples/
│   ├── basic.php                        # port of example.ts (health, list, logs, log detail)
│   └── create_monitor.php               # full HTTPMonitor with assertions/regions
├── src/
│   ├── Gen/                             # buf-generated, committed
│   │   ├── Openstatus/                  # message classes
│   │   ├── GPBMetadata/                 # protobuf descriptors
│   │   └── VERSION
│   ├── OpenstatusClient.php             # root client, nested namespaces
│   ├── ClientOptions.php                # typed value object, named-args ctor
│   ├── Transport/
│   │   └── JsonTransport.php            # PSR-18-based, POST /rpc/{Service}/{Method}, JSON in/out
│   ├── Services/                        # hand-written, 6 files
│   │   ├── HealthServiceClient.php
│   │   ├── MonitorServiceClient.php
│   │   ├── StatusReportServiceClient.php
│   │   ├── StatusPageServiceClient.php
│   │   ├── MaintenanceServiceClient.php
│   │   └── NotificationServiceClient.php
│   ├── Namespaces/                      # `client->monitor->v1` sugar holders, ~12 tiny classes
│   │   ├── Monitor.php
│   │   ├── MonitorV1.php
│   │   └── ...
│   ├── Internal/
│   │   └── ConnectErrorMapper.php       # PSR-18 ResponseInterface -> typed exception
│   └── Exception/
│       ├── OpenstatusException.php      # base; carries connectCode, httpStatus, details, rawBody
│       ├── AuthenticationException.php
│       ├── NotFoundException.php
│       ├── InvalidArgumentException.php
│       ├── PermissionDeniedException.php
│       ├── RateLimitException.php
│       └── ServiceUnavailableException.php
├── tests/
│   ├── Unit/                            # MockHandler-driven, runs in CI
│   └── Integration/                     # gated on OPENSTATUS_API_KEY, NOT run in CI
└── .github/workflows/
    ├── ci.yml                           # phpunit (unit) + phpstan, PHP 8.4
    └── regen.yml                        # weekly: bump pin + open PR

5. composer.json #

{
  "name": "openstatus/sdk-php",
  "description": "Official PHP SDK for Openstatus",
  "license": "MIT",
  "type": "library",
  "keywords": ["openstatus", "monitoring", "status-page", "uptime", "sdk"],
  "require": {
    "php": ">=8.4",
    "ext-json": "*",
    "google/protobuf": "^4.29",
    "psr/http-client": "^1.0",
    "psr/http-factory": "^1.0",
    "psr/http-message": "^2.0",
    "psr/log": "^3.0",
    "guzzlehttp/guzzle": "^7.8"
  },
  "require-dev": {
    "phpunit/phpunit": "^11.3",
    "phpstan/phpstan": "^1.12",
    "friendsofphp/php-cs-fixer": "^3.64"
  },
  "autoload": {
    "psr-4": {
      "Openstatus\\Sdk\\": "src/",
      "Openstatus\\": "src/Gen/Openstatus/",
      "GPBMetadata\\": "src/Gen/GPBMetadata/"
    }
  },
  "autoload-dev": {
    "psr-4": {
      "Openstatus\\Sdk\\Tests\\": "tests/"
    }
  },
  "scripts": {
    "regen": "bash scripts/regen.sh",
    "test": "phpunit",
    "analyse": "phpstan analyse src tests",
    "fmt": "php-cs-fixer fix"
  }
}

Guzzle is required so composer require openstatus/sdk-php works out of the box, but the transport itself depends only on the PSR interfaces — users in Symfony/Laravel apps can swap in their own PSR-18 client.

6. ClientOptions (typed value object) #

namespace Openstatus\Sdk;

use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;

final class ClientOptions
{
    public function __construct(
        public readonly ?string $apiKey = null,           // falls back to OPENSTATUS_API_KEY
        public readonly ?string $baseUrl = null,          // falls back to OPENSTATUS_API_URL, default https://api.openstatus.dev
        public readonly float $timeout = 30.0,            // seconds
        public readonly ?ClientInterface $httpClient = null,
        public readonly ?RequestFactoryInterface $requestFactory = null,
        public readonly ?StreamFactoryInterface $streamFactory = null,
        public readonly ?LoggerInterface $logger = null,  // PSR-3, request/response logged at DEBUG
    ) {}
}

7. Root client & namespace sugar #

Mirrors the Node SDK shape: client.monitor.v1.MonitorService.listMonitors(...). Service property is camelCase (PHP convention) while everything else matches Node:

use Openstatus\Sdk\OpenstatusClient;
use Openstatus\Sdk\ClientOptions;
use Openstatus\Monitor\V1\ListMonitorsRequest;

$client = new OpenstatusClient(new ClientOptions(
    apiKey: getenv('OPENSTATUS_API_KEY'),
));

$res = $client->monitor->v1->monitorService->listMonitors(new ListMonitorsRequest());

OpenstatusClient constructs the JsonTransport once, then builds nested monitor/health/statusReport/statusPage/maintenance/notification holders. Each holder has a ->v1 property pointing to a holder that exposes the typed service client. Total sugar code: ~12 tiny final classes (~5 lines each, leveraging PHP 8.4 property hooks where useful).

Env fallbacks match the Node SDK:

  • OPENSTATUS_API_KEY — API key
  • OPENSTATUS_API_URL — base URL (default https://api.openstatus.dev)

8. JSON transport (PSR-18, ~120 LOC) #

namespace Openstatus\Sdk\Transport;

use Google\Protobuf\Internal\Message;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use Psr\Http\Message\StreamFactoryInterface;
use Psr\Log\LoggerInterface;
use Openstatus\Sdk\Internal\ConnectErrorMapper;

final class JsonTransport
{
    public function __construct(
        private readonly ClientInterface $http,
        private readonly RequestFactoryInterface $requestFactory,
        private readonly StreamFactoryInterface $streamFactory,
        private readonly string $baseUrl,
        private readonly array $defaultHeaders,
        private readonly ?LoggerInterface $logger = null,
    ) {}

    /**
     * @template T of Message
     * @param class-string<T> $responseClass
     * @return T
     */
    public function call(
        string $serviceFqn,
        string $method,
        Message $request,
        string $responseClass,
        array $extraHeaders = [],
    ): Message {
        $url  = rtrim($this->baseUrl, '/') . '/rpc/' . $serviceFqn . '/' . $method;
        $body = $this->streamFactory->createStream($request->serializeToJsonString());

        $req = $this->requestFactory->createRequest('POST', $url)->withBody($body);
        foreach (array_merge($this->defaultHeaders, $extraHeaders) as $name => $value) {
            $req = $req->withHeader($name, $value);
        }

        $started = microtime(true);
        $res = $this->http->sendRequest($req);
        $this->logger?->debug('openstatus.rpc', [
            'service' => $serviceFqn,
            'method'  => $method,
            'status'  => $res->getStatusCode(),
            'ms'      => (int) ((microtime(true) - $started) * 1000),
        ]);

        if ($res->getStatusCode() >= 400) {
            ConnectErrorMapper::rethrow($res);
        }

        $response = new $responseClass();
        $response->mergeFromJsonString((string) $res->getBody());
        return $response;
    }
}

Default headers set by OpenstatusClient:

  • Content-Type: application/json
  • Accept: application/json
  • User-Agent: openstatus-php/<sdk-version> php/<php-version>
  • x-openstatus-key: <api-key> (if apiKey set)

9. Service client wrappers (hand-written, one per service) #

namespace Openstatus\Sdk\Services;

use Openstatus\Monitor\V1 as M;
use Openstatus\Sdk\Transport\JsonTransport;

final class MonitorServiceClient
{
    private const FQN = 'openstatus.monitor.v1.MonitorService';

    public function __construct(private readonly JsonTransport $transport) {}

    public function listMonitors(
        M\ListMonitorsRequest $req,
        array $headers = [],
    ): M\ListMonitorsResponse {
        return $this->transport->call(self::FQN, 'ListMonitors', $req, M\ListMonitorsResponse::class, $headers);
    }

    public function createHTTPMonitor(
        M\CreateHTTPMonitorRequest $req,
        array $headers = [],
    ): M\CreateHTTPMonitorResponse {
        return $this->transport->call(self::FQN, 'CreateHTTPMonitor', $req, M\CreateHTTPMonitorResponse::class, $headers);
    }

    // ... one method per RPC in this service
}

~52 methods across 6 files. Mechanical, easy to review, easy for contributors to add when the schema gains a new RPC. Method names are camelCase (PHP convention); per-call header overrides via optional array $headers = [] second argument.

10. Error mapping #

ConnectErrorMapper decodes a PSR-7 4xx/5xx ResponseInterface and throws a typed subclass.

Connect code PHP exception
unauthenticated AuthenticationException
not_found NotFoundException
invalid_argument InvalidArgumentException
permission_denied PermissionDeniedException
resource_exhausted RateLimitException
unavailable ServiceUnavailableException
anything else OpenstatusException

When the response body is not a valid connect.error envelope (e.g. 502 HTML from a proxy), throw ServiceUnavailableException with the raw body preserved.

OpenstatusException carries:

  • connectCode(): string — the Connect code (e.g. not_found), or 'unknown' when the envelope is missing.
  • httpStatus(): int — the raw HTTP status code.
  • details(): array — the raw details array from the envelope (decoded JSON, no further parsing). Empty array when missing.
  • rawBody(): string — the original response body, for debugging.
  • getMessage() — the message from the envelope, or the HTTP reason phrase as fallback.

11. Testing #

  • Unit tests (PSR-18 mock client, runs on every PR):
    • Transport sends the correct URL (/rpc/openstatus.monitor.v1.MonitorService/ListMonitors).
    • Headers include x-openstatus-key, User-Agent, Content-Type, Accept when configured, plus per-call overrides.
    • JSON body round-trips: serializing a known message and decoding it back produces equivalent state.
    • 4xx with connect.error body → correct typed exception with connectCode, httpStatus, details populated.
    • Non-JSON 5xx → ServiceUnavailableException with rawBody preserved.
  • Integration tests (gated on OPENSTATUS_API_KEY, run locally by maintainers before tagging a release; not wired into CI):
    • HealthService.Check — proves the wire format works against the real API.
    • MonitorService.ListMonitors — proves auth + real JSON decode.
  • CI matrix: PHP 8.4 only (until 8.5 ships). Steps: composer fmt -- --dry-run + composer analyse (phpstan level 8 on src/, excluding src/Gen/) + composer test.

12. README & examples #

Full port of the Node SDK README — same TOC, per-service sections, one example per RPC, error-handling section, regions/enums reference. Users compare SDKs by README completeness; parity matters.

examples/basic.php mirrors example.ts: health check, list monitors, list HTTP response logs, fetch one log detail.

examples/create_monitor.php builds a full HTTPMonitor with Periodicity, Region, HTTPMethod, and a StatusCodeAssertion — the hardest first-time task, worth a worked example.

README also documents:

  • The int|string quirk on int64 fields (timestamps, durations, latencies).
  • Recipes for Symfony (services.yaml registration) and Laravel (AppServiceProvider binding) — five lines each.
  • Migration notes from the Node SDK structure to PHP idioms.

13. Release & ops #

  • Tag v0.1.0 → Packagist auto-syncs (once openstatus/sdk-php is registered).
  • .github/workflows/regen.yml: weekly cron that checks Buf for a newer archive version. If one exists, edits BUF_PHP_VERSION in scripts/regen.sh, runs composer regen, and opens a PR with both changes. The pin never moves silently — every bump is a reviewable commit.
  • CHANGELOG.md in Keep a Changelog format. Update on every release.
  • CONTRIBUTING.md documents: how to bump BUF_PHP_VERSION, how to run tests (composer test, composer analyse, composer fmt), code style rules (PSR-12 + declare(strict_types=1);), PR conventions.
  • This document was the original plan.md; it lives at docs/decisions.md post-v0.1.0 so the rationale (why JSON-over-HTTP, why pinned archive, why mirrored Node shape) survives as a reference.

14. Milestones #

  1. ✅ Bootstrap — composer.json, scripts/regen.sh, .editorconfig, .php-cs-fixer.dist.php, phpstan.neon, phpunit.xml.dist, LICENSE, empty CHANGELOG.md + CONTRIBUTING.md, run regen, commit src/Gen/.
  2. ✅ Transport + error mapping — JsonTransport, ClientOptions, ConnectErrorMapper, typed exception hierarchy, unit tests with PSR-18 mock.
  3. ✅ HealthService end-to-end — smallest service, manual integration check against the real API to validate the wire format.
  4. ✅ Remaining service wrappers — Monitor → StatusReport → StatusPage → Maintenance → Notification (one PR per service, each with unit tests).
  5. ✅ Root client + namespace sugar — OpenstatusClient with nested ->v1->serviceName accessors, env-var fallbacks.
  6. ✅ README + examples — full port from Node SDK, examples/basic.php + examples/create_monitor.php.
  7. ✅ CI — ci.yml (phpunit unit + phpstan, PHP 8.4), regen.yml (weekly cron).
  8. ✅ v0.1.0 release prep on Packagist + move plan.md → docs/decisions.md.

15. Trade-offs vs alternatives #

Option Codegen step Type safety Service stubs Wire format
Buf archive + JSON (chosen) curl + unzip ✅ (protobuf msgs) ❌ hand-write JSON
Buf CLI + protoc-gen-php local buf generate ✅ ❌ hand-write JSON or bin
OpenAPI Generator npx openapi-gen ✅ ✅ generated JSON
Hand-written + arrays none ❌ ❌ hand-write JSON

The archive approach is the leanest: no toolchain dependency for contributors (just curl), type-safe message classes that match the proto source of truth byte-for-byte, and only ~120 lines of transport + ~52 one-liners of service dispatch to maintain ourselves.