From 027d1dcf8b701d9f5fa217a867e4bd01c50a0656 Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Tue, 3 Nov 2026 02:59:26 +0100 Subject: [PATCH] Add Open Graph image retrieval for 0.3 hosts --- CHANGELOG.md | 22 +++ README.md | 37 ++++ src/Embed/EmbedRequest.php | 26 +++ src/Embed/NativeSsrOgImageTransport.php | 186 +++++++++++++++++++++ src/Embed/Renderer.php | 56 +------ src/Embed/SsrCacheKey.php | 4 +- src/Embed/SsrOgImage.php | 18 ++ src/Embed/SsrOgImageClient.php | 54 ++++++ src/Embed/SsrOgImageTransport.php | 26 +++ src/Embed/SsrRequestPayload.php | 82 +++++++++ tests/NativeTransportsTest.php | 109 ++++++++++++ tests/SsrOgImageClientTest.php | 213 ++++++++++++++++++++++++ tests/fixtures/http-server.php | 42 +++++ 13 files changed, 820 insertions(+), 55 deletions(-) create mode 100644 src/Embed/NativeSsrOgImageTransport.php create mode 100644 src/Embed/SsrOgImage.php create mode 100644 src/Embed/SsrOgImageClient.php create mode 100644 src/Embed/SsrOgImageTransport.php create mode 100644 src/Embed/SsrRequestPayload.php create mode 100644 tests/SsrOgImageClientTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 896ea82..45827c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 0.3.2 — Unreleased + +Open Graph cards from the sidecar (`POST /v1/og`, `@mapsight/ssr-sidecar` +0.2) for the host's own public OG endpoint. No change for hosts that do not +use it. + +- `SsrOgImageClient::fetch(EmbedRequest)` posts the placement's render + payload to `{ssrUrl}/v1/og` and returns `SsrOgImage` (`contentType`, + `body`), or null when `ssrUrl` is unset, the request URL has no + `?feature=` / `?module=`, the breaker is open, or the sidecar has no card + (404) or fails. Serve the static card then. Transport: + `SsrOgImageTransport` / `NativeSsrOgImageTransport` (cURL, else streams). +- `EmbedRequest::$ogImageEndpoint` (explicit field, else + `config.ogImageEndpoint`, else env `MAPSIGHT_OG_IMAGE_ENDPOINT`): the + absolute URL of that endpoint, forwarded as `options.ogImageEndpoint`. The + sidecar then links `{endpoint}?feature=…&module=…&src=…` as the + `og:image` of a place. Part of `SsrCacheKey`. +- Internal `SsrRequestPayload` builds the v1 body and headers for both the + renderer and the card client, so they resolve the same place. +- The card transport rejects redirects, non-image or empty responses and + images larger than 4 MiB. Requests retain the 256 KiB body cap. + ## 0.3.1 — 2026-10-29 PHP 8.5 deprecation fixes for the 0.3 line. No API change. diff --git a/README.md b/README.md index 4c68706..a9c87cb 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,43 @@ echo $result->html; ]); ``` +## Open Graph cards + +Sidecar 0.2 renders a place's Open Graph card on `POST /v1/og` (same body as +`/v1/render`). The sidecar stays off public ingress, so the host serves the +card from an endpoint of its own and tells the sidecar about it: + +```php +use OpenMapsight\Embed\SsrOgImageClient; + +// On the page: `ogImageEndpoint` makes the sidecar link +// {endpoint}?feature=…&module=…&src=… as og:image of a place. +$request = new EmbedRequest( + preset: 'simpleMap', + containerId: 'mapsight-embed-1', + config: $mapOptions, // the same preset options used on the page + ssrUrl: 'http://127.0.0.1:4123', + requestUrl: '/map/?feature=poi-1', // use the endpoint's validated share params + pageOrigin: 'https://www.example.com', + ogImageEndpoint: 'https://www.example.com/mapsight/og.php', +); + +// On that endpoint, with the same EmbedRequest (requestUrl carrying the +// feature, module and src params of the request): +$image = (new SsrOgImageClient())->fetch($request); +if ($image === null) { + header('Location: https://www.example.com/plan/img/og-default.png', true, 302); + exit; +} +header('Content-Type: ' . $image->contentType); +header('Cache-Control: public, max-age=3600'); +echo $image->body; +``` + +`fetch()` is fail-open like the renderer: null without `ssrUrl`, without +`?feature=` / `?module=` on `requestUrl`, while the breaker is open, and on +a sidecar `404` or error. + ```bash composer install composer test diff --git a/src/Embed/EmbedRequest.php b/src/Embed/EmbedRequest.php index 3431994..42f0c35 100644 --- a/src/Embed/EmbedRequest.php +++ b/src/Embed/EmbedRequest.php @@ -28,6 +28,7 @@ public function __construct( public readonly ?string $requestUrl = null, public readonly ?string $pageOrigin = null, public readonly ?string $ogImage = null, + public readonly ?string $ogImageEndpoint = null, ) { if ($this->preset === '') { throw new \InvalidArgumentException('preset must not be empty'); @@ -94,6 +95,31 @@ public function resolvedPageOrigin(): ?string return null; } + /** + * Absolute URL of the host's own Open Graph endpoint (a `SsrOgImageClient` + * behind it). Places then link `{endpoint}?feature=…` instead of the + * static card. Explicit field, then config.ogImageEndpoint, then env + * MAPSIGHT_OG_IMAGE_ENDPOINT. + */ + public function resolvedOgImageEndpoint(): ?string + { + if ($this->ogImageEndpoint !== null && $this->ogImageEndpoint !== '') { + return $this->ogImageEndpoint; + } + + $fromConfig = $this->config['ogImageEndpoint'] ?? null; + if (is_string($fromConfig) && $fromConfig !== '') { + return $fromConfig; + } + + $fromEnv = getenv('MAPSIGHT_OG_IMAGE_ENDPOINT'); + if (is_string($fromEnv) && $fromEnv !== '') { + return $fromEnv; + } + + return null; + } + /** * Absolute or root-absolute static default OG card. Null lets the sidecar * use `{pageOrigin}/plan/img/og-default.png`. diff --git a/src/Embed/NativeSsrOgImageTransport.php b/src/Embed/NativeSsrOgImageTransport.php new file mode 100644 index 0000000..8123afd --- /dev/null +++ b/src/Embed/NativeSsrOgImageTransport.php @@ -0,0 +1,186 @@ + self::MAX_BODY_BYTES) { + throw new \RuntimeException('SSR request body exceeds size cap'); + } + + if (function_exists('curl_init')) { + [$status, $contentType, $body] = $this->postWithCurl($url, $json, $timeoutSeconds, $connectTimeoutSeconds, $headers); + } else { + [$status, $contentType, $body] = $this->postWithStreams($url, $json, $timeoutSeconds, $headers); + } + + return self::finish($status, $contentType, $body); + } + + /** + * @param array $headers + * @return array{0: int, 1: ?string, 2: string} + */ + private function postWithCurl( + string $url, + string $json, + float $timeoutSeconds, + float $connectTimeoutSeconds, + array $headers, + ): array { + $headerLines = ['Content-Type: application/json']; + $headers += ['Accept' => 'image/png, application/json']; + foreach ($headers as $name => $value) { + $headerLines[] = $name . ': ' . $value; + } + + $handle = curl_init($url); + if ($handle === false) { + throw new \RuntimeException('SSR og request failed'); + } + + $body = ''; + $limited = false; + curl_setopt_array($handle, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $json, + CURLOPT_HTTPHEADER => $headerLines, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_WRITEFUNCTION => static function (mixed $handle, string $chunk) use (&$body, &$limited): int { + // Keep one extra byte to let the shared response validation + // reject the image while retaining HTTP status and headers. + $remaining = self::MAX_IMAGE_BYTES + 1 - strlen($body); + if (strlen($chunk) > $remaining) { + $body .= substr($chunk, 0, $remaining); + $limited = true; + + return 0; + } + $body .= $chunk; + + return strlen($chunk); + }, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_CONNECTTIMEOUT_MS => max(1, (int) round($connectTimeoutSeconds * 1000)), + CURLOPT_TIMEOUT_MS => max(1, (int) round($timeoutSeconds * 1000)), + ]); + + $completed = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + $contentType = curl_getinfo($handle, CURLINFO_CONTENT_TYPE); + $error = curl_error($handle); + unset($handle); + + if ($completed === false && !$limited) { + throw new \RuntimeException($error !== '' ? $error : 'SSR og request failed'); + } + + return [$status, is_string($contentType) && $contentType !== '' ? $contentType : null, $body]; + } + + /** + * @param array $headers + * @return array{0: int, 1: ?string, 2: string} + */ + private function postWithStreams(string $url, string $json, float $timeoutSeconds, array $headers): array + { + $headerLines = [ + 'Content-Type: application/json', + 'Content-Length: ' . strlen($json), + ]; + $headers += ['Accept' => 'image/png, application/json']; + foreach ($headers as $name => $value) { + $headerLines[] = $name . ': ' . $value; + } + + $context = stream_context_create([ + 'http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $headerLines) . "\r\n", + 'content' => $json, + 'timeout' => $timeoutSeconds, + 'ignore_errors' => true, + 'follow_location' => 0, + ], + ]); + + // Response headers come from the stream metadata: PHP 8.5 deprecates + // $http_response_header, and http_get_last_response_headers() needs 8.4. + $stream = @fopen($url, 'rb', false, $context); + if ($stream === false) { + throw new \RuntimeException('SSR og request failed'); + } + + try { + $headers = stream_get_meta_data($stream)['wrapper_data'] ?? null; + $body = stream_get_contents($stream, self::MAX_IMAGE_BYTES + 1); + $timedOut = stream_get_meta_data($stream)['timed_out'] ?? false; + } finally { + fclose($stream); + } + + if ($body === false || $timedOut) { + throw new \RuntimeException('SSR og request failed'); + } + + $status = 0; + $contentType = null; + $contentLength = null; + if (is_array($headers)) { + if (is_string($headers[0] ?? null) && preg_match('/\s(\d{3})\s/', $headers[0], $matches) === 1) { + $status = (int) $matches[1]; + } + foreach ($headers as $line) { + if (is_string($line) && stripos($line, 'Content-Type:') === 0) { + $contentType = trim(substr($line, strlen('Content-Type:'))); + } + if (is_string($line) && preg_match('/^Content-Length:\s*([0-9]+)\s*$/i', $line, $matches) === 1) { + $contentLength = (int) $matches[1]; + } + } + } + + if (strlen($body) <= self::MAX_IMAGE_BYTES && $contentLength !== null && strlen($body) !== $contentLength) { + throw new \RuntimeException('SSR og response is incomplete'); + } + + return [$status, $contentType, $body]; + } + + private static function finish(int $status, ?string $contentType, string $body): ?SsrOgImage + { + if ($status === 404) { + return null; + } + if ($status < 200 || $status >= 300) { + throw new \RuntimeException('SSR og HTTP status ' . $status); + } + $type = strtolower(trim((string) strtok((string) $contentType, ';'))); + if (!str_starts_with($type, 'image/')) { + throw new \RuntimeException('SSR og response Content-Type must be an image'); + } + if ($body === '' || strlen($body) > self::MAX_IMAGE_BYTES) { + throw new \RuntimeException('SSR og response has no usable image'); + } + + return new SsrOgImage($type, $body); + } +} diff --git a/src/Embed/Renderer.php b/src/Embed/Renderer.php index 7a48636..3dc01fe 100644 --- a/src/Embed/Renderer.php +++ b/src/Embed/Renderer.php @@ -57,22 +57,11 @@ public function renderDocument(EmbedRequest $request): RenderedEmbed try { $transport = $this->ssrTransport ?? new NativeSsrTransport(); $renderUrl = rtrim($request->ssrUrl, '/') . '/v1/render'; - $payload = [ - 'v' => 1, - 'preset' => $request->preset, - 'options' => $this->ssrOptions($request), - ]; - if ($request->requestId !== null && $request->requestId !== '') { - $payload['requestId'] = $request->requestId; - } - if ($request->assetVersion !== null && $request->assetVersion !== '') { - $payload['assetVersion'] = $request->assetVersion; - } $document = $transport->postJson( $renderUrl, - $payload, + SsrRequestPayload::body($request), $request->ssrTimeoutSeconds, - self::ssrHeaders($request), + SsrRequestPayload::headers($request), $request->ssrConnectTimeoutSeconds, ); $this->circuitBreaker->recordSuccess(); @@ -105,33 +94,6 @@ public function renderDocument(EmbedRequest $request): RenderedEmbed return new RenderedEmbed(implode("\n", $parts) . "\n", $pageMeta); } - /** - * @return array - */ - private function ssrOptions(EmbedRequest $request): array - { - $options = array_merge($request->config, [ - 'containerId' => $request->containerId, - ]); - if ($request->containerClassName !== '') { - $options['containerClassName'] = $request->containerClassName; - } - $requestUrl = $request->resolvedRequestUrl(); - if ($requestUrl !== null) { - $options['requestUrl'] = $requestUrl; - } - $pageOrigin = $request->resolvedPageOrigin(); - if ($pageOrigin !== null) { - $options['pageOrigin'] = $pageOrigin; - } - $ogImage = $request->resolvedOgImage(); - if ($ogImage !== null) { - $options['ogImage'] = $ogImage; - } - - return $options; - } - private function pageMetaForRequest(EmbedRequest $request, ?PlacePageMeta $pageMeta): ?PlacePageMeta { if (!$request->requestHasPageMetaParam()) { @@ -141,20 +103,6 @@ private function pageMetaForRequest(EmbedRequest $request, ?PlacePageMeta $pageM return $pageMeta; } - /** @return array */ - private static function ssrHeaders(EmbedRequest $request): array - { - $headers = ['Accept' => 'application/json']; - if ($request->requestId !== null && $request->requestId !== '') { - $headers['X-Request-Id'] = $request->requestId; - } - if ($request->assetVersion !== null && $request->assetVersion !== '') { - $headers['X-Mapsight-Asset-Version'] = $request->assetVersion; - } - - return $headers; - } - private function emptyContainer(EmbedRequest $request): string { $id = $this->escapeAttr($request->containerId); diff --git a/src/Embed/SsrCacheKey.php b/src/Embed/SsrCacheKey.php index f8f6dff..b2987f7 100644 --- a/src/Embed/SsrCacheKey.php +++ b/src/Embed/SsrCacheKey.php @@ -8,7 +8,8 @@ * Stable key for a v1 SSR placement: config + locale/deviceClass + assetVersion * + requestUrl + contract. requestUrl must be in the key so `?module=` / * `?feature=` (and similar search) does not reuse another URL's HTML. - * pageOrigin / ogImage are in the key so cached pageMeta stays absolute. + * pageOrigin / ogImage / ogImageEndpoint are in the key so cached pageMeta + * stays absolute and links the right card. */ final class SsrCacheKey { @@ -26,6 +27,7 @@ public static function for(EmbedRequest $request): string 'requestUrl' => $request->resolvedRequestUrl(), 'pageOrigin' => $request->resolvedPageOrigin(), 'ogImage' => $request->resolvedOgImage(), + 'ogImageEndpoint' => $request->resolvedOgImageEndpoint(), ]; return hash( diff --git a/src/Embed/SsrOgImage.php b/src/Embed/SsrOgImage.php new file mode 100644 index 0000000..65cdd0e --- /dev/null +++ b/src/Embed/SsrOgImage.php @@ -0,0 +1,18 @@ +circuitBreaker = $circuitBreaker ?? new ProcessSsrCircuitBreaker(); + } + + public function fetch(EmbedRequest $request): ?SsrOgImage + { + if ($request->ssrUrl === null || $request->ssrUrl === '' || !$request->requestHasPageMetaParam()) { + return null; + } + if (!$this->circuitBreaker->allow()) { + return null; + } + + try { + $transport = $this->transport ?? new NativeSsrOgImageTransport(); + $image = $transport->postOgImage( + rtrim($request->ssrUrl, '/') . '/v1/og', + SsrRequestPayload::body($request), + $request->ssrTimeoutSeconds, + ['Accept' => 'image/png, application/json'] + SsrRequestPayload::headers($request), + $request->ssrConnectTimeoutSeconds, + ); + $this->circuitBreaker->recordSuccess(); + + return $image; + } catch (\Throwable) { + $this->circuitBreaker->recordFailure(); + + return null; + } + } +} diff --git a/src/Embed/SsrOgImageTransport.php b/src/Embed/SsrOgImageTransport.php new file mode 100644 index 0000000..11c6f1f --- /dev/null +++ b/src/Embed/SsrOgImageTransport.php @@ -0,0 +1,26 @@ + $payload + * @param array $headers + * + * @return SsrOgImage|null null when the sidecar has no card (HTTP 404) + * + * @throws \Throwable when the sidecar is unreachable or answers with anything else + */ + public function postOgImage( + string $url, + array $payload, + float $timeoutSeconds, + array $headers = [], + float $connectTimeoutSeconds = 0.1, + ): ?SsrOgImage; +} diff --git a/src/Embed/SsrRequestPayload.php b/src/Embed/SsrRequestPayload.php new file mode 100644 index 0000000..b424141 --- /dev/null +++ b/src/Embed/SsrRequestPayload.php @@ -0,0 +1,82 @@ + + */ + public static function body(EmbedRequest $request): array + { + $payload = [ + 'v' => 1, + 'preset' => $request->preset, + 'options' => self::options($request), + ]; + if ($request->requestId !== null && $request->requestId !== '') { + $payload['requestId'] = $request->requestId; + } + if ($request->assetVersion !== null && $request->assetVersion !== '') { + $payload['assetVersion'] = $request->assetVersion; + } + + return $payload; + } + + /** + * @return array + */ + public static function options(EmbedRequest $request): array + { + $options = array_merge($request->config, [ + 'containerId' => $request->containerId, + ]); + if ($request->containerClassName !== '') { + $options['containerClassName'] = $request->containerClassName; + } + $requestUrl = $request->resolvedRequestUrl(); + if ($requestUrl !== null) { + $options['requestUrl'] = $requestUrl; + } + $pageOrigin = $request->resolvedPageOrigin(); + if ($pageOrigin !== null) { + $options['pageOrigin'] = $pageOrigin; + } + $ogImage = $request->resolvedOgImage(); + if ($ogImage !== null) { + $options['ogImage'] = $ogImage; + } + $ogImageEndpoint = $request->resolvedOgImageEndpoint(); + if ($ogImageEndpoint !== null) { + $options['ogImageEndpoint'] = $ogImageEndpoint; + } + + return $options; + } + + /** + * @return array + */ + public static function headers(EmbedRequest $request): array + { + $headers = ['Accept' => 'application/json']; + if ($request->requestId !== null && $request->requestId !== '') { + $headers['X-Request-Id'] = $request->requestId; + } + if ($request->assetVersion !== null && $request->assetVersion !== '') { + $headers['X-Mapsight-Asset-Version'] = $request->assetVersion; + } + + return $headers; + } +} diff --git a/tests/NativeTransportsTest.php b/tests/NativeTransportsTest.php index f3c8562..3682e0a 100644 --- a/tests/NativeTransportsTest.php +++ b/tests/NativeTransportsTest.php @@ -4,6 +4,7 @@ namespace OpenMapsight\Tests; +use OpenMapsight\Embed\NativeSsrOgImageTransport; use OpenMapsight\Embed\NativeSsrPurgeTransport; use OpenMapsight\Embed\NativeSsrTransport; use PHPUnit\Framework\Attributes\DataProvider; @@ -86,6 +87,114 @@ public function test_purge_reads_status(string $method): void $this->assertSame('[]', $this->purge($method, '/purge?status=204')); } + #[DataProvider('paths')] + public function test_og_reads_the_card_on_2xx(string $method): void + { + [$status, $contentType, $body] = $this->og($method, '/v1/og', '{"v":1,"options":{"requestUrl":"/plan/?feature=poi-1"}}', ['X-Request-Id' => 'req-9']); + + $this->assertSame(200, $status); + $this->assertSame('image/png', $contentType); + $this->assertStringStartsWith("\x89PNG", $body); + $this->assertStringEndsWith('req-9', $body); + } + + #[DataProvider('paths')] + public function test_og_404_means_no_card_and_5xx_throws(string $method): void + { + [$status] = $this->og($method, '/v1/og', '{"v":1,"options":{"requestUrl":"/plan/"}}'); + $this->assertSame(404, $status); + + $transport = new NativeSsrOgImageTransport(); + $this->assertNull($transport->postOgImage( + 'http://127.0.0.1:' . self::$port . '/v1/og', + ['v' => 1, 'options' => ['requestUrl' => '/plan/']], + 2.0, + )); + $this->assertSame( + 'image/png', + $transport->postOgImage( + 'http://127.0.0.1:' . self::$port . '/v1/og', + ['v' => 1, 'options' => ['requestUrl' => '/plan/?feature=poi-1']], + 2.0, + )?->contentType, + ); + + $this->expectExceptionMessage('SSR og HTTP status 503'); + $transport->postOgImage('http://127.0.0.1:' . self::$port . '/v1/og?status=503', ['v' => 1], 2.0); + } + + #[DataProvider('paths')] + public function test_og_transport_does_not_follow_redirects(string $method): void + { + [$status, , $body] = $this->og($method, '/v1/og?status=302', '{}'); + + $this->assertSame(302, $status); + $this->assertSame('redirect', $body); + } + + #[DataProvider('paths')] + public function test_og_stops_reading_an_oversized_response_and_preserves_its_headers(string $method): void + { + [$status, $contentType, $body] = $this->og( + $method, + '/v1/og?image=large', + '{"v":1,"options":{"requestUrl":"/plan/?feature=poi-1"}}', + ); + + $this->assertSame(200, $status); + $this->assertSame('image/png', $contentType); + $this->assertSame(NativeSsrOgImageTransport::MAX_IMAGE_BYTES + 1, strlen($body)); + } + + public function test_og_rejects_non_image_empty_and_oversized_responses(): void + { + $transport = new NativeSsrOgImageTransport(); + foreach (['html' => 'Content-Type', 'empty' => 'usable image', 'large' => 'usable image'] as $kind => $message) { + try { + $transport->postOgImage( + 'http://127.0.0.1:' . self::$port . '/v1/og?image=' . $kind, + ['v' => 1, 'options' => ['requestUrl' => '/plan/?feature=poi-1']], + 2.0, + ); + $this->fail('Accepted ' . $kind . ' response'); + } catch (\RuntimeException $error) { + $this->assertStringContainsString($message, $error->getMessage()); + } + } + } + + #[DataProvider('paths')] + public function test_og_rejects_a_truncated_image_response(string $method): void + { + $this->expectException(\RuntimeException::class); + $this->og($method, '/v1/og?image=truncated', '{"options":{"requestUrl":"/map/?feature=poi-1"}}'); + } + + public function test_og_rejects_an_oversized_request_before_contacting_the_sidecar(): void + { + $this->expectExceptionMessage('SSR request body exceeds size cap'); + (new NativeSsrOgImageTransport())->postOgImage( + 'http://127.0.0.1:1/v1/og', + ['padding' => str_repeat('x', 262144)], + 0.1, + ); + } + + /** + * @param array $headers + * @return array{0: int, 1: ?string, 2: string} + */ + private function og(string $method, string $path, string $json, array $headers = []): array + { + $transport = new NativeSsrOgImageTransport(); + $url = 'http://127.0.0.1:' . self::$port . $path; + $args = $method === 'postWithCurl' + ? [$url, $json, 2.0, 0.5, $headers] + : [$url, $json, 2.0, $headers]; + + return (new \ReflectionMethod($transport, $method))->invoke($transport, ...$args); + } + /** @param array $headers */ private function render(string $method, string $path, array $headers = []): string { diff --git a/tests/SsrOgImageClientTest.php b/tests/SsrOgImageClientTest.php new file mode 100644 index 0000000..3609e59 --- /dev/null +++ b/tests/SsrOgImageClientTest.php @@ -0,0 +1,213 @@ +transport(new SsrOgImage('image/png', "\x89PNG")); + $client = new SsrOgImageClient($transport); + + $image = $client->fetch($this->request(['requestId' => 'og-1', 'assetVersion' => 'bundle-1'])); + + $this->assertSame("\x89PNG", $image?->body); + $this->assertSame('image/png', $image?->contentType); + $this->assertSame('http://ssr:4123/v1/og', $transport->url); + $this->assertSame(1, $transport->payload['v']); + $this->assertSame('placeMap', $transport->payload['preset']); + $this->assertSame('og-1', $transport->payload['requestId']); + $this->assertSame('/plan/?module=landmarks&feature=poi-1', $transport->payload['options']['requestUrl']); + $this->assertSame('https://www.example.de/mapsight/og.php', $transport->payload['options']['ogImageEndpoint']); + $this->assertSame('https://www.example.de', $transport->payload['options']['pageOrigin']); + $this->assertSame('host-map', $transport->payload['options']['containerId']); + $this->assertSame(1, $transport->payload['options']['project']['version']); + $this->assertSame('og-1', $transport->headers['X-Request-Id']); + $this->assertSame('bundle-1', $transport->headers['X-Mapsight-Asset-Version']); + $this->assertSame('bundle-1', $transport->payload['assetVersion']); + $this->assertSame('image/png, application/json', $transport->headers['Accept']); + $this->assertSame(3.0, $transport->timeoutSeconds); + $this->assertSame(0.2, $transport->connectTimeoutSeconds); + } + + public function test_is_null_without_sidecar_or_share_param(): void + { + $transport = $this->transport(new SsrOgImage('image/png', "\x89PNG")); + $client = new SsrOgImageClient($transport); + + $this->assertNull($client->fetch($this->request(['ssrUrl' => null]))); + $this->assertNull($client->fetch($this->request(['requestUrl' => '/plan/']))); + $this->assertSame(0, $transport->calls); + } + + public function test_is_null_when_the_sidecar_has_no_card(): void + { + $breaker = $this->breaker(); + $client = new SsrOgImageClient($this->transport(null), $breaker); + + $this->assertNull($client->fetch($this->request())); + $this->assertSame(1, $breaker->successes); + $this->assertSame(0, $breaker->failures); + } + + public function test_fails_open_and_counts_a_failure(): void + { + $breaker = $this->breaker(); + $client = new SsrOgImageClient($this->transport(new \RuntimeException('SSR og HTTP status 503')), $breaker); + + $this->assertNull($client->fetch($this->request())); + $this->assertSame(1, $breaker->failures); + } + + public function test_skips_the_sidecar_while_the_breaker_is_open(): void + { + $breaker = $this->breaker(false); + $transport = $this->transport(new SsrOgImage('image/png', "\x89PNG")); + $client = new SsrOgImageClient($transport, $breaker); + + $this->assertNull($client->fetch($this->request())); + $this->assertSame(0, $transport->calls); + } + + public function test_endpoint_resolves_from_field_config_or_env_and_keys_the_cache(): void + { + $explicit = $this->request(); + $fromConfig = $this->request(['ogImageEndpoint' => null, 'config' => ['ogImageEndpoint' => 'https://cfg.example.de/og']]); + $none = $this->request(['ogImageEndpoint' => null]); + + $this->assertSame('https://www.example.de/mapsight/og.php', $explicit->resolvedOgImageEndpoint()); + $this->assertSame('https://cfg.example.de/og', $fromConfig->resolvedOgImageEndpoint()); + $this->assertNull($none->resolvedOgImageEndpoint()); + $this->assertArrayNotHasKey('ogImageEndpoint', SsrRequestPayload::options($none)); + $this->assertNotSame(SsrCacheKey::for($explicit), SsrCacheKey::for($none)); + + putenv('MAPSIGHT_OG_IMAGE_ENDPOINT=https://env.example.de/og'); + try { + $this->assertSame('https://env.example.de/og', $none->resolvedOgImageEndpoint()); + } finally { + putenv('MAPSIGHT_OG_IMAGE_ENDPOINT'); + } + } + + public function test_source_and_module_hints_reach_the_sidecar_and_separate_cache_entries(): void + { + $transport = $this->transport(new SsrOgImage('image/png', "\x89PNG")); + $client = new SsrOgImageClient($transport); + $url = '/plan/?module=landmarks&feature=shared-id&src=places'; + $places = $this->request(['requestUrl' => $url]); + $otherSource = $this->request(['requestUrl' => str_replace('src=places', 'src=museums', $url)]); + $otherModule = $this->request(['requestUrl' => str_replace('module=landmarks', 'module=culture', $url)]); + + $this->assertNotNull($client->fetch($places)); + $this->assertSame($url, $transport->payload['options']['requestUrl']); + $this->assertNotSame(SsrCacheKey::for($places), SsrCacheKey::for($otherSource)); + $this->assertNotSame(SsrCacheKey::for($places), SsrCacheKey::for($otherModule)); + } + + /** + * @param array $overrides + */ + private function request(array $overrides = []): EmbedRequest + { + $args = [ + 'preset' => 'placeMap', + 'containerId' => 'host-map', + 'config' => ['project' => ['version' => 1]], + 'ssrUrl' => 'http://ssr:4123', + 'ssrTimeoutSeconds' => 3.0, + 'ssrConnectTimeoutSeconds' => 0.2, + 'requestUrl' => '/plan/?module=landmarks&feature=poi-1', + 'pageOrigin' => 'https://www.example.de', + 'ogImage' => '/plan/img/og-default.png', + 'ogImageEndpoint' => 'https://www.example.de/mapsight/og.php', + ]; + + return new EmbedRequest(...array_merge($args, $overrides)); + } + + private function transport(SsrOgImage|\Throwable|null $result): SsrOgImageTransport&RecordingOgTransport + { + return new class($result) implements SsrOgImageTransport, RecordingOgTransport { + public int $calls = 0; + public string $url = ''; + /** @var array */ + public array $payload = []; + /** @var array */ + public array $headers = []; + public float $timeoutSeconds = 0.0; + public float $connectTimeoutSeconds = 0.0; + + public function __construct(private readonly SsrOgImage|\Throwable|null $result) + { + } + + public function postOgImage( + string $url, + array $payload, + float $timeoutSeconds, + array $headers = [], + float $connectTimeoutSeconds = 0.1, + ): ?SsrOgImage { + $this->calls++; + $this->url = $url; + $this->payload = $payload; + $this->headers = $headers; + $this->timeoutSeconds = $timeoutSeconds; + $this->connectTimeoutSeconds = $connectTimeoutSeconds; + if ($this->result instanceof \Throwable) { + throw $this->result; + } + + return $this->result; + } + }; + } + + private function breaker(bool $allow = true): SsrCircuitBreaker&CountingBreaker + { + return new class($allow) implements SsrCircuitBreaker, CountingBreaker { + public int $successes = 0; + public int $failures = 0; + + public function __construct(private readonly bool $allowed) + { + } + + public function allow(): bool + { + return $this->allowed; + } + + public function recordSuccess(): void + { + $this->successes++; + } + + public function recordFailure(): void + { + $this->failures++; + } + }; + } +} + +/** @internal typed access to the anonymous transport's recordings */ +interface RecordingOgTransport +{ +} + +/** @internal typed access to the anonymous breaker's counters */ +interface CountingBreaker +{ +} diff --git a/tests/fixtures/http-server.php b/tests/fixtures/http-server.php index e35978d..218b2c3 100644 --- a/tests/fixtures/http-server.php +++ b/tests/fixtures/http-server.php @@ -24,6 +24,48 @@ exit; } +if ($path === '/v1/og') { + if (($query['status'] ?? '') === '302') { + http_response_code(302); + header('Location: /v1/og'); + header('Content-Type: text/plain'); + echo 'redirect'; + exit; + } + if (($query['status'] ?? '') === '503') { + http_response_code(503); + header('Content-Type: text/plain'); + echo 'down'; + exit; + } + + $body = json_decode((string) file_get_contents('php://input'), true); + $requestUrl = is_array($body) ? ($body['options']['requestUrl'] ?? '') : ''; + if (!is_string($requestUrl) || !str_contains($requestUrl, 'feature=')) { + http_response_code(404); + header('Content-Type: application/json'); + echo '{"v":1,"error":{"code":"NOT_FOUND","message":"no og image for this request"}}'; + exit; + } + + http_response_code(200); + header('Content-Type: ' . (($query['image'] ?? '') === 'html' ? 'text/html' : 'image/png')); + if (($query['image'] ?? '') === 'empty') { + exit; + } + if (($query['image'] ?? '') === 'large') { + echo str_repeat('x', 8388608); + exit; + } + if (($query['image'] ?? '') === 'truncated') { + header('Content-Length: 100'); + echo "\x89PNG\r\n\x1a\n"; + exit; + } + echo "\x89PNG\r\n\x1a\n" . ($_SERVER['HTTP_X_REQUEST_ID'] ?? ''); + exit; +} + if ($path === '/purge') { if (($query['status'] ?? '') === '204') { http_response_code(204);