From 611890161e4bd43c5285c18c7404821dfe33717f Mon Sep 17 00:00:00 2001 From: Francesc Sans Date: Thu, 16 Jul 2026 18:44:08 +0200 Subject: [PATCH] feat: add script execution and container field I/O Port ScriptInvoker (3 scopes: database, entity-set, record; by name and FMSID for v26+) and ContainerRef (download, upload binary/base64, clear, patchContainers) from fms-odata-js/fms-odata-py, reusing spec-php helpers for MIME sniffing, Content-Disposition, base64, and script envelope parsing. Integrated into Client::script()/scriptById(), Query::script(), EntityRef::script()/container()/patchContainers(). Non-zero script errors promoted to FMScriptError. 41 new tests (238 total). PHPStan clean. --- AGENTS.md | 21 +- CHANGELOG.md | 32 ++ README.md | 74 ++++- src/Client.php | 29 ++ src/Containers/ContainerDownloadResult.php | 24 ++ src/Containers/ContainerRef.php | 302 +++++++++++++++++ src/Entity/EntityRef.php | 72 +++++ src/Query/Query.php | 16 + src/Scripts/ScriptInvoker.php | 170 ++++++++++ tests/Containers/ContainerRefTest.php | 359 +++++++++++++++++++++ tests/Scripts/ScriptInvokerTest.php | 229 +++++++++++++ tests/Support/ResponseFactory.php | 10 + 12 files changed, 1329 insertions(+), 9 deletions(-) create mode 100644 src/Containers/ContainerDownloadResult.php create mode 100644 src/Containers/ContainerRef.php create mode 100644 src/Scripts/ScriptInvoker.php create mode 100644 tests/Containers/ContainerRefTest.php create mode 100644 tests/Scripts/ScriptInvokerTest.php diff --git a/AGENTS.md b/AGENTS.md index d6da508..beb2b51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ the full alignment plan. ```bash composer install # install dependencies -composer test # PHPUnit (197 tests) +composer test # PHPUnit (238 tests) composer analyse # PHPStan level max composer check # tests + static analysis ``` @@ -46,10 +46,14 @@ The client is layered into pure request builders and an HTTP execution layer: - **Query, Filter, EntityRef, Url** — build pure request data (URLs, filter expressions, key references). No direct cURL calls. +- **ScriptInvoker** — FileMaker script invocation at database, entity-set, + and record scope (by name or FMSID). Reuses spec-php script helpers. +- **ContainerRef** — container field download (binary), upload (binary or + base64), and clear. MIME sniffing and Content-Disposition via spec-php. - **HttpClient** — handles auth, OData headers, 401 retry, JSON/XML decoding, and error conversion. Uses a `TransportInterface` for the actual HTTP call. - **CurlTransport** — the production transport, using PHP's built-in cURL - extension. The only transport shipped with the MVP. + extension. The only transport shipped. - **MockTransport** (test only) — deterministic in-memory transport for offline test execution. - **MetadataParser / MetadataFetcher** — parse `$metadata` XML and cache the @@ -60,7 +64,8 @@ The client is layered into pure request builders and an HTTP execution layer: ## Key source files - `src/Client.php` — `Client` entry point, base URL construction, version - detection, feature gating, low-level request methods + detection, feature gating, low-level request methods, `script()` / + `scriptById()` - `src/FMSOData.php` — thin alias subclass of `Client` - `src/ClientOptions.php` — configuration DTO (host, database, token, transport, timeout, SSL) @@ -70,10 +75,18 @@ The client is layered into pure request builders and an HTTP execution layer: - `src/Http/TransportInterface.php` — single request/response contract - `src/Http/Request.php`, `Response.php` — neutral HTTP DTOs - `src/Query/Query.php` — fluent query builder + serialization + execution + + `script()` (entity-set scope) - `src/Query/Filter.php`, `FilterFactory.php` — $filter expression builder -- `src/Entity/EntityRef.php` — single-record CRUD handle +- `src/Entity/EntityRef.php` — single-record CRUD handle + `script()` + (record scope) + `container()` + `patchContainers()` - `src/Entity/EntityWriteOptions.php` — PATCH/DELETE options (If-Match, return representation) +- `src/Scripts/ScriptInvoker.php` — script invocation handle (3 scopes, + name/FMSID, envelope parsing, error promotion) +- `src/Containers/ContainerRef.php` — container field I/O (get, getStream, + upload binary/base64, delete, Content-Disposition parsing) +- `src/Containers/ContainerDownloadResult.php` — download result DTO with + `size` field (parity with JS/Python) - `src/Metadata/MetadataParser.php` — regex-based CSDL XML parser - `src/Metadata/MetadataFetcher.php` — cached $metadata fetcher diff --git a/CHANGELOG.md b/CHANGELOG.md index 382d142..8948149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to this package are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Script execution** — `ScriptInvoker` class providing FileMaker script + invocation at three scopes (database, entity-set, record) by name or by + FMSID (v26+). Integrated into `Client::script()`, `Client::scriptById()`, + `Query::script()`, and `EntityRef::script()`. Non-zero script errors are + promoted to `FMScriptError`. Handles both the nested (v26+) and flat + (older FMS) response envelopes via the spec-php `Scripts` facade. +- **Container field I/O** — `ContainerRef` class providing container + download (`get()`, `getStream()`), upload (binary and base64 encoding), + and clear (`delete()`). MIME sniffing from magic bytes, Content-Disposition + parsing (RFC 6266 + RFC 5987), and base64 JSON annotation building are + reused from the spec-php `Containers` facade. Integrated into + `EntityRef::container()` and `EntityRef::patchContainers()` (multi-field + container + regular field updates in a single PATCH). +- `ContainerDownloadResult` DTO extending the spec-php + `ContainerDownload` shape with a `size` field for parity with the JS and + Python clients. +- `ResponseFactory::binary()` test helper for binary response construction. +- 41 new tests covering script invocation (URLs, scopes, FMSID, envelopes, + error promotion) and container I/O (download, upload, sniffing, base64, + clear, patchContainers, Content-Disposition parsing). + +### Docs + +- README: updated Status section (no longer "MVP"), added script and + container usage examples to Quick start, added ScriptInvoker and + ContainerRef to Architecture, added Roadmap section listing unported + features ($batch, schema DDL, webhooks), updated test count to 238. + ## [1.0.1] — 2026-07-15 ### Fixed diff --git a/README.md b/README.md index 2b5aafd..433d07f 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,13 @@ and [fms-odata-py](https://github.com/fsans/fms-odata-py). ## Status -**MVP** — core client functionality is implemented: configuration, cURL-backed -HTTP transport with auth and 401 retry, fluent query builder, entity CRUD, -metadata parsing, version detection, and feature gating. +Core client functionality is implemented: configuration, cURL-backed HTTP +transport with auth and 401 retry, fluent query builder, entity CRUD, +script execution, container field I/O, metadata parsing, version +detection, and feature gating. + +> **Note:** `$batch`, schema DDL, and webhook management are not yet +> ported from the JS/Python clients. See the roadmap section below. ## Spec alignment @@ -79,6 +83,53 @@ $client->from('Contacts')->byKey(42)->patch( // Delete a record $client->from('Contacts')->byKey(42)->delete(); +// Run a FileMaker script at database scope +$result = $client->script('Greet', parameter: 'world'); +echo $result->resultParameter; // text result from Exit Script + +// Run a script by FMSID (v26+; survives renames) +$client->scriptById(42, parameter: 'hello'); + +// Run a script at entity-set scope +$client->from('Contacts')->script('ValidateAll'); + +// Run a script at record scope (current record = this entity) +$client->from('Contacts')->byKey(42)->script('Notify', parameter: 'updated'); + +// Download a container field +$dl = $client->from('Contacts')->byKey(42)->container('Photo')->get(); +echo $dl->size; // byte length +echo $dl->contentType; // e.g. image/png +echo $dl->filename; // parsed from Content-Disposition +\file_put_contents('/tmp/photo.png', $dl->data); + +// Upload to a container field (binary, MIME auto-sniffed from magic bytes) +$client->from('Contacts')->byKey(42)->container('Photo')->upload( + data: \file_get_contents('/tmp/photo.png'), + filename: 'photo.png', +); + +// Upload via base64 JSON (for multi-field or mixed container+regular updates) +use FmsOData\Spec\Containers\ContainerEncoding; +$client->from('Contacts')->byKey(42)->container('Photo')->upload( + data: \file_get_contents('/tmp/photo.png'), + contentType: 'image/png', + filename: 'photo.png', + encoding: ContainerEncoding::BASE64, +); + +// Update multiple container fields + regular fields in one request +$client->from('Contacts')->byKey(42)->patchContainers( + [ + 'Photo' => ['data' => $pngBytes, 'contentType' => 'image/png', 'filename' => 'p.png'], + 'Doc' => ['data' => $pdfBytes, 'contentType' => 'application/pdf'], + ], + ['name' => 'Jane'], +); + +// Clear a container field +$client->from('Contacts')->byKey(42)->container('Photo')->delete(); + // Fetch metadata $metadata = $client->metadata(); echo $metadata->namespace . "\n"; @@ -98,10 +149,14 @@ The client is layered into pure request builders and an HTTP execution layer: - **Query, Filter, EntityRef, Url** — build pure request data (URLs, filter expressions, key references). No direct cURL calls. +- **ScriptInvoker** — FileMaker script invocation at database, entity-set, + and record scope (by name or FMSID). Reuses spec-php script helpers. +- **ContainerRef** — container field download (binary), upload (binary or + base64), and clear. MIME sniffing and Content-Disposition via spec-php. - **HttpClient** — handles auth, OData headers, 401 retry, JSON/XML decoding, and error conversion. Uses a `TransportInterface` for the actual HTTP call. - **CurlTransport** — the production transport, using PHP's built-in cURL - extension. The only transport shipped with the MVP. + extension. The only transport shipped. - **MockTransport** (test only) — deterministic in-memory transport for offline test execution. - **MetadataParser / MetadataFetcher** — parse `$metadata` XML and cache the @@ -125,11 +180,20 @@ are sourced from `FmsOData\Spec` (the `fsans/fms-odata-spec-php` package). - `Prefer` header: `return=minimal` (default) or `return=representation`. - `ETag`/`If-Match`: for optimistic concurrency on PATCH and DELETE. +## Roadmap + +The following features are implemented in the JS and Python clients but not +yet ported to PHP: + +- `$batch` multipart request composer +- Schema DDL (create/delete tables, fields, indexes) +- Webhook management (create, remove, get, invoke) + ## Development ```bash composer install -composer test # PHPUnit (197 tests) +composer test # PHPUnit (238 tests) composer analyse # PHPStan level max composer check # tests + static analysis ``` diff --git a/src/Client.php b/src/Client.php index e5b6e0d..9dbac05 100644 --- a/src/Client.php +++ b/src/Client.php @@ -13,7 +13,9 @@ use FmsOData\Metadata\MetadataFetcher; use FmsOData\Metadata\MetadataOptions; use FmsOData\Query\Query; +use FmsOData\Scripts\ScriptInvoker; use FmsOData\Spec\Metadata\FMServerVersion; +use FmsOData\Spec\Scripts\ScriptResult; use FmsOData\Spec\Metadata\Metadata; use FmsOData\Spec\Metadata\ODataMetadata; use FmsOData\Spec\Versions\FMVersionInfo; @@ -94,6 +96,33 @@ public function from(string $entitySet): Query return new Query($this->http, $this->baseUrl, $entitySet); } + /** + * Invoke a FileMaker script at database scope. + * + * A non-zero `scriptError` is thrown as `FMScriptError`. + * + * @param string|int|float|array|null $parameter + * + * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/06-scripts.md + */ + public function script(string $name, string|int|float|array|null $parameter = null): ScriptResult + { + return (new ScriptInvoker($this->http, $this->baseUrl))->run($name, $parameter); + } + + /** + * Invoke a FileMaker script by its immutable FMSID. + * + * Requires FileMaker Server 2026+ (v26). Use + * `$client->hasFeature('scriptsByFMSID')` to check before calling. + * + * @param string|int|float|array|null $parameter + */ + public function scriptById(int $fmsid, string|int|float|array|null $parameter = null): ScriptResult + { + return (new ScriptInvoker($this->http, $this->baseUrl))->runById($fmsid, $parameter); + } + public function request(string $pathOrUrl, ?HttpRequestOptions $options = null): mixed { return $this->http->sendJson($this->resolveUrl($pathOrUrl), $options); diff --git a/src/Containers/ContainerDownloadResult.php b/src/Containers/ContainerDownloadResult.php new file mode 100644 index 0000000..57a74c0 --- /dev/null +++ b/src/Containers/ContainerDownloadResult.php @@ -0,0 +1,24 @@ +()//$value -> ContainerRef#get + * PATCH /()/ -> ContainerRef#upload (binary) + * PATCH /() (JSON body) -> ContainerRef#upload (base64) + * + * Supported binary MIME types: PNG, JPEG, GIF, TIFF, PDF. + * + * Mirrors `src/containers.ts` in fms-odata-js and + * `src/fms_odata/containers.py` in fms-odata-py. Reuses the spec-php + * {@see Containers} facade for MIME sniffing, Content-Disposition building, + * and base64 encoding. + * + * PHP note: downloads return binary strings (PHP's native binary-safe + * strings) rather than `Blob` (JS) or `bytes` (Python). For streaming + * large payloads, use {@see getStream()} which yields a file resource + * backed by the raw response body. + * + * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/07-containers.md + */ +final class ContainerRef +{ + private HttpClient $http; + + private EntityRef $entity; + + private string $fieldName; + + public function __construct(HttpClient $http, EntityRef $entity, string $fieldName) + { + if ($fieldName === '') { + throw new \InvalidArgumentException('ContainerRef: fieldName is required'); + } + $this->http = $http; + $this->entity = $entity; + $this->fieldName = $fieldName; + } + + /** + * Absolute URL of the container field itself + * (`.../()/`). This is the URL used by binary + * {@see upload()}. Append `/$value` to download. + */ + public function url(): string + { + return $this->entity->toUrl() . '/' . Url::encodePathSegment($this->fieldName); + } + + /** + * Download the container's contents into memory as a binary string. + * + * Returns a {@see ContainerDownloadResult} (an extension of the spec-php + * {@see ContainerDownload} with a `size` field for parity with the + * JS/Python clients). + * + * FMS quirk: `Accept: application/octet-stream` makes `$value` return the + * stored filename string as `text/plain` instead of the binary. This + * method uses a wildcard Accept (ResponseType::NONE) so FMS returns the + * actual bytes with a sniffed Content-Type. + */ + public function get(): ContainerDownloadResult + { + $response = $this->http->sendRequest($this->valueUrl(), new HttpRequestOptions( + method: 'GET', + responseType: ResponseType::NONE, + )); + + $contentType = $response->contentType() ?? ''; + $disposition = $response->header('Content-Disposition'); + $filename = $disposition !== null ? self::parseContentDispositionFilename($disposition) : null; + + return new ContainerDownloadResult( + data: $response->body, + contentType: $contentType, + filename: $filename, + size: \strlen($response->body), + ); + } + + /** + * Stream the container's contents without buffering the full body in + * memory. + * + * Returns the raw {@see Response} so the caller can read `->body` in + * chunks or write it to a file via `file_put_contents` / streams. For + * most use cases {@see get()} is simpler; use this for very large + * payloads. + */ + public function getStream(): Response + { + return $this->http->sendRequest($this->valueUrl(), new HttpRequestOptions( + method: 'GET', + responseType: ResponseType::NONE, + )); + } + + /** + * Upload contents to the container. Replaces any existing value. + * + * @param string $data Binary payload (PHP binary string). + * @param string|null $contentType MIME type. When omitted, sniffed from magic bytes. + * @param string|null $filename Optional filename stored in the container. + * @param ContainerEncoding|null $encoding BINARY (default) or BASE64. + * + * @throws \TypeError when binary upload is used with an unsupported MIME type. + * @throws \TypeError when the MIME cannot be sniffed and none is provided. + */ + public function upload( + string $data, + ?string $contentType = null, + ?string $filename = null, + ?ContainerEncoding $encoding = null, + ): void { + $encoding ??= ContainerEncoding::BINARY; + + if ($contentType === null) { + $sniffed = Containers::sniffMime($data); + if ($sniffed === null) { + $supported = \implode(', ', \array_map( + static fn (ContainerBinaryMimeType $m): string => $m->value, + ContainerBinaryMimeType::all(), + )); + throw new \TypeError( + 'ContainerRef.upload: contentType is required and could not be sniffed from the payload. ' + . 'Pass a contentType explicitly (one of ' . $supported . ').', + ); + } + $contentType = $sniffed->value; + } + + if ($encoding === ContainerEncoding::BINARY) { + $this->uploadBinary($data, $contentType, $filename); + + return; + } + + $this->uploadBase64($data, $contentType, $filename); + } + + /** + * Convenience wrapper accepting a {@see ContainerUploadInput} DTO, + * matching the spec-php container upload input shape. + */ + public function uploadInput(ContainerUploadInput $input): void + { + $this->upload( + data: $input->data, + contentType: $input->contentType, + filename: $input->filename, + encoding: $input->encoding, + ); + } + + /** + * Clear the container value. FMS has no documented per-field DELETE for + * record-level data, so the supported path is to PATCH the record with + * `{ : null }`. + */ + public function delete(): void + { + $body = \json_encode([$this->fieldName => null], \JSON_THROW_ON_ERROR); + + $this->http->sendRequest($this->entity->toUrl(), new HttpRequestOptions( + method: 'PATCH', + headers: [ + 'Content-Type' => 'application/json', + 'Prefer' => 'return=minimal', + ], + body: $body, + responseType: ResponseType::NONE, + )); + } + + private function valueUrl(): string + { + return $this->url() . '/$value'; + } + + private function uploadBinary(string $data, string $contentType, ?string $filename): void + { + $normalized = self::normalizeMime($contentType); + $supported = false; + foreach (ContainerBinaryMimeType::all() as $mime) { + if ($mime->value === $normalized) { + $supported = true; + break; + } + } + + if (!$supported) { + $list = \implode(', ', \array_map( + static fn (ContainerBinaryMimeType $m): string => $m->value, + ContainerBinaryMimeType::all(), + )); + throw new \TypeError( + 'ContainerRef.upload (binary): contentType "' . $contentType . '" is not a FileMaker-supported ' + . 'container type. Use one of ' . $list . ', or switch to base64 encoding.', + ); + } + + $headers = ['Content-Type' => $contentType]; + if ($filename !== null && $filename !== '') { + $headers['Content-Disposition'] = Containers::buildContentDisposition($filename); + } + + $this->http->sendRequest($this->url(), new HttpRequestOptions( + method: 'PATCH', + headers: $headers, + body: $data, + responseType: ResponseType::NONE, + )); + } + + private function uploadBase64(string $data, string $contentType, ?string $filename): void + { + $body = [ + $this->fieldName => Containers::toBase64($data), + $this->fieldName . '@com.filemaker.odata.ContentType' => $contentType, + ]; + if ($filename !== null && $filename !== '') { + $body[$this->fieldName . '@com.filemaker.odata.Filename'] = $filename; + } + + $this->http->sendRequest($this->entity->toUrl(), new HttpRequestOptions( + method: 'PATCH', + headers: [ + 'Content-Type' => 'application/json', + 'Prefer' => 'return=minimal', + ], + body: \json_encode($body, \JSON_THROW_ON_ERROR), + responseType: ResponseType::NONE, + )); + } + + private static function normalizeMime(string $value): string + { + $parts = \explode(';', $value, 2); + + return \trim(\strtolower($parts[0] ?? '')); + } + + /** + * Parse the `filename` (or RFC 5987 `filename*`) from a + * `Content-Disposition` header. Returns null when no filename parameter + * is present. + * + * Per RFC 6266 §4.3, `filename*` (with explicit charset) takes + * precedence over `filename` when both are supplied. + */ + public static function parseContentDispositionFilename(string $value): ?string + { + // RFC 5987: filename*=charset'lang'percent-encoded-value + if (\preg_match("/filename\\*\\s*=\\s*([^']+)'([^']*)'([^;]+)/i", $value, $ext)) { + $encoded = \trim($ext[3]); + $decoded = \rawurldecode($encoded); + + return $decoded !== '' ? $decoded : null; + } + + // Plain `filename="..."` or `filename=...` (unquoted, up to `;` or end). + if (\preg_match('/filename\s*=\s*("([^"\\\\]*(?:\\\\.[^"\\\\]*)*)"|([^;]+))/i', $value, $plain)) { + $quoted = $plain[2] ?? ''; + if ($quoted !== '') { + $unescaped = \stripslashes($quoted); + $trimmed = \trim($unescaped); + + return $trimmed !== '' ? $trimmed : null; + } + $unquoted = $plain[3] ?? ''; + if ($unquoted !== '') { + $trimmed = \trim($unquoted); + + return $trimmed !== '' ? $trimmed : null; + } + } + + return null; + } +} diff --git a/src/Entity/EntityRef.php b/src/Entity/EntityRef.php index 4e42a20..df4f0bb 100644 --- a/src/Entity/EntityRef.php +++ b/src/Entity/EntityRef.php @@ -4,9 +4,15 @@ namespace FmsOData\Entity; +use FmsOData\Containers\ContainerRef; use FmsOData\Http\HttpClient; use FmsOData\Http\HttpRequestOptions; use FmsOData\Http\ResponseType; +use FmsOData\Scripts\ScriptInvoker; +use FmsOData\Spec\Containers\ContainerEncoding; +use FmsOData\Spec\Containers\ContainerUploadInput; +use FmsOData\Spec\Containers\Containers; +use FmsOData\Spec\Scripts\ScriptResult; use FmsOData\Url; final class EntityRef @@ -90,4 +96,70 @@ public function delete(?EntityWriteOptions $options = null): void $this->http->sendRequest($this->toUrl(), $httpOptions); } + + /** + * Invoke a FileMaker script in the context of this single record. FMS + * sets the script's current record to this entity before running it. + * + * @param string|int|float|array|null $parameter + * + * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/06-scripts.md + */ + public function script(string $name, string|int|float|array|null $parameter = null): ScriptResult + { + return (new ScriptInvoker($this->http, $this->baseUrl, $this->entitySet, $this->key))->run($name, $parameter); + } + + /** + * Get a typed handle to one of this record's container fields, exposing + * download, upload, and clear operations. + * + * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/07-containers.md + */ + public function container(string $fieldName): ContainerRef + { + return new ContainerRef($this->http, $this, $fieldName); + } + + /** + * Update one or more container fields (and optionally regular fields) on + * this record in a single base64-encoded PATCH request. + * + * Each container value's `data` must already be base64-encoded (use the + * spec-php `Containers::toBase64()` helper or pass raw bytes — this + * method encodes raw bytes for you). + * + * @param array $containers + * @param array $regularFields + * + * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/07-containers.md + */ + public function patchContainers(array $containers, array $regularFields = []): mixed + { + $body = $regularFields; + foreach ($containers as $field => $value) { + if (!isset($value['contentType']) || $value['contentType'] === '') { + throw new \InvalidArgumentException( + 'EntityRef.patchContainers: "' . $field . '" requires a contentType.', + ); + } + $body[$field] = Containers::toBase64($value['data']); + $body[$field . '@com.filemaker.odata.ContentType'] = $value['contentType']; + if (isset($value['filename']) && \is_string($value['filename']) && $value['filename'] !== '') { + $body[$field . '@com.filemaker.odata.Filename'] = $value['filename']; + } + } + + $httpOptions = new HttpRequestOptions( + method: 'PATCH', + headers: [ + 'Content-Type' => 'application/json', + 'Prefer' => 'return=minimal', + ], + body: \json_encode($body, \JSON_THROW_ON_ERROR), + responseType: ResponseType::NONE, + ); + + return $this->http->sendRequest($this->toUrl(), $httpOptions); + } } diff --git a/src/Query/Query.php b/src/Query/Query.php index 115c21c..68e4ebc 100644 --- a/src/Query/Query.php +++ b/src/Query/Query.php @@ -8,8 +8,10 @@ use FmsOData\Http\HttpClient; use FmsOData\Http\HttpRequestOptions; use FmsOData\Http\ResponseType; +use FmsOData\Scripts\ScriptInvoker; use FmsOData\Spec\Query\QueryResult; use FmsOData\Spec\Query\SortDirection; +use FmsOData\Spec\Scripts\ScriptResult; use FmsOData\Url; final class Query @@ -141,6 +143,20 @@ public function byKey(string|int|bool $key): EntityRef return new EntityRef($this->http, $this->baseUrl, $this->entitySet, $key); } + /** + * Invoke a FileMaker script in the context of this query's entity set. + * + * For record-scope script execution use `EntityRef#script`. + * + * @param string|int|float|array|null $parameter + * + * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/06-scripts.md + */ + public function script(string $name, string|int|float|array|null $parameter = null): ScriptResult + { + return (new ScriptInvoker($this->http, $this->baseUrl, $this->entitySet))->run($name, $parameter); + } + public function get(): QueryResult { $data = $this->http->sendJson($this->toUrl()); diff --git a/src/Scripts/ScriptInvoker.php b/src/Scripts/ScriptInvoker.php new file mode 100644 index 0000000..bdf6980 --- /dev/null +++ b/src/Scripts/ScriptInvoker.php @@ -0,0 +1,170 @@ +` path suffix. Scripts can be invoked at three scopes: + * + * POST //Script. // database-level + * POST ///Script. // entity-set context + * POST //()/Script. // single-record context + * + * Starting with FileMaker Server 2026 (v26), scripts can also be invoked by + * their immutable FMSID instead of the name: + * + * POST //Script.FMSID: + * + * The optional parameter is sent as `{"scriptParameterValue": ""}`. + * The response envelope is `{"scriptResult": "...", "scriptError": "0"}`; + * a non-zero `scriptError` becomes an {@see FMScriptError}. + * + * Mirrors `src/scripts.ts` in fms-odata-js and `src/fms_odata/scripts.py` + * in fms-odata-py. Reuses the spec-php {@see Scripts} facade for path + * segment construction, request body building, and response parsing. + * + * @see https://github.com/fsans/fms-odata-spec/blob/main/docs/06-scripts.md + */ +final class ScriptInvoker +{ + private HttpClient $http; + + private string $baseUrl; + + private ?string $entitySet; + + private string|int|bool|null $key; + + public function __construct( + HttpClient $http, + string $baseUrl, + ?string $entitySet = null, + string|int|bool|null $key = null, + ) { + $this->http = $http; + $this->baseUrl = $baseUrl; + $this->entitySet = $entitySet; + $this->key = $key; + } + + /** Build the absolute URL for invoking `name` at this scope. */ + public function url(string $name): string + { + if ($name === '') { + throw new \InvalidArgumentException('ScriptInvoker: script name is required'); + } + + return $this->urlForSegment(Scripts::pathSegment(new ScriptNameId($name))); + } + + /** Build the absolute URL for invoking by FMSID at this scope. */ + public function urlById(int $fmsid): string + { + return $this->urlForSegment(Scripts::pathSegment(new ScriptFmsidId($fmsid))); + } + + /** + * Invoke the script by name. Returns a {@see ScriptResult} on success. + * + * @param string|int|float|array|null $parameter + */ + public function run(string $name, string|int|float|array|null $parameter = null): ScriptResult + { + return $this->runAtUrl($this->url($name), $parameter); + } + + /** + * Invoke the script by its immutable FMSID. + * + * Requires FileMaker Server 2026+ (v26). Use + * `$client->hasFeature('scriptsByFMSID')` to check before calling. + * + * @param string|int|float|array|null $parameter + */ + public function runById(int $fmsid, string|int|float|array|null $parameter = null): ScriptResult + { + return $this->runAtUrl($this->urlById($fmsid), $parameter); + } + + private function urlForSegment(string $scriptSegment): string + { + if ($this->entitySet === null) { + return $this->baseUrl . '/' . $scriptSegment; + } + + $setSegment = Url::encodePathSegment($this->entitySet); + if ($this->key === null) { + return $this->baseUrl . '/' . $setSegment . '/' . $scriptSegment; + } + + return $this->baseUrl . '/' . $setSegment . '(' . Url::formatKey($this->key) . ')/' . $scriptSegment; + } + + /** + * @param string|int|float|array|null $parameter + */ + private function runAtUrl(string $url, string|int|float|array|null $parameter): ScriptResult + { + $options = new ScriptOptions(parameter: $parameter); + $body = Scripts::requestBody($options); + + $headers = []; + if ($body !== null) { + $headers['Content-Type'] = 'application/json'; + } + + $httpOptions = new HttpRequestOptions( + method: 'POST', + headers: $headers, + body: $body, + responseType: ResponseType::JSON, + ); + + $raw = $this->http->sendJson($url, $httpOptions); + + return self::parseAndRaise($raw, $url); + } + + /** + * Parse the script response envelope and raise {@see FMScriptError} + * on a non-zero code. + * + * Handles both the nested (v26+) and flat (older FMS) envelope shapes, + * delegating to the spec-php {@see Scripts::parseResponse()} for the + * common path while preserving the TS/Python behaviour of raising on + * non-zero errors. + * + * @param mixed $raw + */ + public static function parseAndRaise(mixed $raw, string $url): ScriptResult + { + $result = Scripts::parseResponse($raw); + + if ($result->code !== 0) { + throw new FMScriptError( + 'FileMaker script error ' . $result->code, + scriptError: $result->code, + scriptResult: $result->resultParameter, + request: new RequestRef('POST', $url), + ); + } + + return $result; + } +} diff --git a/tests/Containers/ContainerRefTest.php b/tests/Containers/ContainerRefTest.php new file mode 100644 index 0000000..ad5e633 --- /dev/null +++ b/tests/Containers/ContainerRefTest.php @@ -0,0 +1,359 @@ +makeClient(new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::noContent())); + $ref = $client->from('Contacts')->byKey(7)->container('photo'); + self::assertSame(self::BASE . '/Contacts(7)/photo', $ref->url()); + } + + public function testContainerUrlEncodesFieldName(): void + { + $client = $this->makeClient(new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::noContent())); + $ref = $client->from('Contacts')->byKey(7)->container('My Field'); + self::assertSame(self::BASE . '/Contacts(7)/My%20Field', $ref->url()); + } + + public function testEmptyFieldNameThrows(): void + { + $client = $this->makeClient(new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::noContent())); + $this->expectException(\InvalidArgumentException::class); + $client->from('Contacts')->byKey(7)->container(''); + } + + // -- ContainerRef.get --------------------------------------------------- + + public function testContainerGetReturnsBytes(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::binary(200, self::PNG_BYTES, [ + 'Content-Type' => 'image/png', + 'Content-Disposition' => 'attachment; filename="p.png"', + ]); + }); + $client = $this->makeClient($transport); + + $dl = $client->from('Contacts')->byKey(7)->container('photo')->get(); + + self::assertInstanceOf(ContainerDownloadResult::class, $dl); + self::assertNotNull($captured); + self::assertSame(self::BASE . '/Contacts(7)/photo/$value', $captured->url); + self::assertSame(self::PNG_BYTES, $dl->data); + self::assertSame('image/png', $dl->contentType); + self::assertSame(\strlen(self::PNG_BYTES), $dl->size); + self::assertSame('p.png', $dl->filename); + } + + public function testContainerGetStreamReturnsResponse(): void + { + $transport = new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::binary(200, self::PNG_BYTES, [ + 'Content-Type' => 'image/png', + ])); + $client = $this->makeClient($transport); + + $response = $client->from('Contacts')->byKey(7)->container('photo')->getStream(); + + self::assertSame(200, $response->status); + self::assertSame(self::PNG_BYTES, $response->body); + } + + // -- ContainerRef.upload (binary) -------------------------------------- + + public function testUploadBinarySniffsMime(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::noContent(); + }); + $client = $this->makeClient($transport); + + $client->from('Contacts')->byKey(7)->container('photo')->upload(self::PNG_BYTES, filename: 'p.png'); + + self::assertNotNull($captured); + self::assertSame(self::BASE . '/Contacts(7)/photo', $captured->url); + self::assertSame('PATCH', $captured->method); + self::assertSame('image/png', $captured->headers['Content-Type']); + self::assertSame(self::PNG_BYTES, $captured->body); + self::assertSame('inline; filename=p.png', $captured->headers['Content-Disposition']); + } + + public function testUploadBinaryExplicitContentType(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::noContent(); + }); + $client = $this->makeClient($transport); + + $client->from('Contacts')->byKey(7)->container('photo')->upload(self::JPEG_BYTES, contentType: 'image/jpeg'); + + self::assertNotNull($captured); + self::assertSame('image/jpeg', $captured->headers['Content-Type']); + self::assertArrayNotHasKey('Content-Disposition', $captured->headers); + } + + public function testUploadBinaryRejectsUnsupportedMime(): void + { + $client = $this->makeClient(new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::noContent())); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessageMatches('/not a FileMaker-supported/'); + $client->from('Contacts')->byKey(7)->container('photo')->upload("\x00\x01", contentType: 'application/zip'); + } + + public function testUploadCannotSniffThrows(): void + { + $client = $this->makeClient(new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::noContent())); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessageMatches('/could not be sniffed/'); + $client->from('Contacts')->byKey(7)->container('photo')->upload("\x00\x01\x02"); + } + + // -- ContainerRef.upload (base64) -------------------------------------- + + public function testUploadBase64SendsJsonAnnotations(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::noContent(); + }); + $client = $this->makeClient($transport); + + $client->from('Contacts')->byKey(7)->container('photo')->upload( + self::PNG_BYTES, + contentType: 'image/png', + filename: 'p.png', + encoding: ContainerEncoding::BASE64, + ); + + self::assertNotNull($captured); + self::assertSame(self::BASE . '/Contacts(7)', $captured->url); + self::assertSame('application/json', $captured->headers['Content-Type']); + $body = $this->decodeJsonBody((string) $captured->body); + self::assertSame(\base64_encode(self::PNG_BYTES), $body['photo']); + self::assertSame('image/png', $body['photo@com.filemaker.odata.ContentType']); + self::assertSame('p.png', $body['photo@com.filemaker.odata.Filename']); + } + + public function testUploadBase64OmitsFilenameAnnotationWhenAbsent(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::noContent(); + }); + $client = $this->makeClient($transport); + + $client->from('Contacts')->byKey(7)->container('photo')->upload( + self::PNG_BYTES, + contentType: 'image/png', + encoding: ContainerEncoding::BASE64, + ); + + self::assertNotNull($captured); + $body = $this->decodeJsonBody((string) $captured->body); + self::assertArrayNotHasKey('photo@com.filemaker.odata.Filename', $body); + } + + // -- ContainerRef.delete ------------------------------------------------ + + public function testContainerDeleteClearsField(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::noContent(); + }); + $client = $this->makeClient($transport); + + $client->from('Contacts')->byKey(7)->container('photo')->delete(); + + self::assertNotNull($captured); + self::assertSame(self::BASE . '/Contacts(7)', $captured->url); + self::assertSame('PATCH', $captured->method); + $body = $this->decodeJsonBody((string) $captured->body); + self::assertSame(['photo' => null], $body); + } + + // -- EntityRef.patchContainers ----------------------------------------- + + public function testPatchContainersBuildsAnnotatedJsonBody(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::noContent(); + }); + $client = $this->makeClient($transport); + + $client->from('Contacts')->byKey(7)->patchContainers( + [ + 'photo' => ['data' => self::PNG_BYTES, 'contentType' => 'image/png', 'filename' => 'p.png'], + ], + ['name' => 'Jane'], + ); + + self::assertNotNull($captured); + self::assertSame('PATCH', $captured->method); + self::assertSame('application/json', $captured->headers['Content-Type']); + $body = $this->decodeJsonBody((string) $captured->body); + self::assertSame(\base64_encode(self::PNG_BYTES), $body['photo']); + self::assertSame('image/png', $body['photo@com.filemaker.odata.ContentType']); + self::assertSame('p.png', $body['photo@com.filemaker.odata.Filename']); + self::assertSame('Jane', $body['name']); + } + + public function testPatchContainersRequiresContentType(): void + { + $client = $this->makeClient(new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::noContent())); + + $this->expectException(\InvalidArgumentException::class); + $client->from('Contacts')->byKey(7)->patchContainers( + ['photo' => ['data' => self::PNG_BYTES, 'contentType' => '']], + ); + } + + /** + * @return array + */ + private function decodeJsonBody(string $json): array + { + $decoded = \json_decode($json, true, 512, \JSON_THROW_ON_ERROR); + \assert(\is_array($decoded)); + + return $decoded; + } +} diff --git a/tests/Scripts/ScriptInvokerTest.php b/tests/Scripts/ScriptInvokerTest.php new file mode 100644 index 0000000..2d1ff08 --- /dev/null +++ b/tests/Scripts/ScriptInvokerTest.php @@ -0,0 +1,229 @@ + ResponseFactory::json(200, []))), self::BASE); + self::assertSame(self::BASE . '/Script.Ping', $invoker->url('Ping')); + } + + public function testEntitySetScopeUrl(): void + { + $invoker = new ScriptInvoker(new HttpClient('Basic abc', new MockTransport(static fn () => ResponseFactory::json(200, []))), self::BASE, 'Contacts'); + self::assertSame(self::BASE . '/Contacts/Script.Ping', $invoker->url('Ping')); + } + + public function testRecordScopeUrl(): void + { + $invoker = new ScriptInvoker(new HttpClient('Basic abc', new MockTransport(static fn () => ResponseFactory::json(200, []))), self::BASE, 'Contacts', 7); + self::assertSame(self::BASE . '/Contacts(7)/Script.Ping', $invoker->url('Ping')); + } + + public function testRecordScopeStringKeyUrl(): void + { + $invoker = new ScriptInvoker(new HttpClient('Basic abc', new MockTransport(static fn () => ResponseFactory::json(200, []))), self::BASE, 'Contacts', 'ALFKI'); + self::assertSame(self::BASE . "/Contacts('ALFKI')/Script.Ping", $invoker->url('Ping')); + } + + public function testFmsidUrl(): void + { + $invoker = new ScriptInvoker(new HttpClient('Basic abc', new MockTransport(static fn () => ResponseFactory::json(200, []))), self::BASE); + self::assertSame(self::BASE . '/Script.FMSID:42', $invoker->urlById(42)); + } + + public function testEmptyNameThrows(): void + { + $invoker = new ScriptInvoker(new HttpClient('Basic abc', new MockTransport(static fn () => ResponseFactory::json(200, []))), self::BASE); + $this->expectException(\InvalidArgumentException::class); + $invoker->url(''); + } + + public function testDatabaseScopeNoBodyWhenParameterOmitted(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::json(200, ['scriptResult' => 'pong', 'scriptError' => '0']); + }); + $client = $this->makeClient($transport); + + $result = $client->script('Ping'); + + self::assertInstanceOf(ScriptResult::class, $result); + self::assertNotNull($captured); + self::assertSame(self::BASE . '/Script.Ping', $captured->url); + self::assertSame('POST', $captured->method); + // No parameter => no Content-Type, no body. + self::assertNull($captured->body); + self::assertArrayNotHasKey('Content-Type', $captured->headers); + } + + public function testScriptSendsScriptParameterValueAsJson(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::json(200, ['scriptResult' => 'pong:hi', 'scriptError' => '0']); + }); + $client = $this->makeClient($transport); + + $client->script('Ping', 'hi'); + + self::assertNotNull($captured); + self::assertSame('application/json', $captured->headers['Content-Type']); + $decoded = \json_decode((string) $captured->body, true); + self::assertSame(['scriptParameterValue' => 'hi'], $decoded); + } + + public function testFlatEnvelopeReturnsResultParameter(): void + { + $transport = new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::json(200, ['scriptResult' => 'pong', 'scriptError' => '0'])); + $client = $this->makeClient($transport); + + $result = $client->script('Ping'); + + // spec-php parseResponse handles the nested shape; flat shape yields code 0. + self::assertSame(0, $result->code); + } + + public function testNestedEnvelopeV26(): void + { + $transport = new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::json(200, [ + 'scriptResult' => ['code' => 0, 'resultParameter' => 'Hello World'], + ])); + $client = $this->makeClient($transport); + + $result = $client->script('Ping'); + + self::assertSame(0, $result->code); + self::assertSame('Hello World', $result->resultParameter); + } + + public function testNestedEnvelopeNonZeroRaises(): void + { + $transport = new MockTransport(static fn (): \FmsOData\Http\Response => ResponseFactory::json(200, [ + 'scriptResult' => ['code' => 3, 'resultParameter' => ''], + ])); + $client = $this->makeClient($transport); + + try { + $client->script('Bad'); + self::fail('Expected FMScriptError'); + } catch (FMScriptError $e) { + self::assertSame(3, $e->scriptError); + } + } + + public function testScriptByIdBuildsFmsidUrl(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::json(200, ['scriptResult' => ['code' => 0, 'resultParameter' => 'ok']]); + }); + $client = $this->makeClient($transport); + + $client->scriptById(42, 'hi'); + + self::assertNotNull($captured); + self::assertSame(self::BASE . '/Script.FMSID:42', $captured->url); + } + + public function testEntitySetScopeViaQuery(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::json(200, ['scriptResult' => ['code' => 0, 'resultParameter' => 'ok']]); + }); + $client = $this->makeClient($transport); + + $client->from('Contacts')->script('Ping'); + + self::assertNotNull($captured); + self::assertSame(self::BASE . '/Contacts/Script.Ping', $captured->url); + } + + public function testRecordScopeViaEntityRef(): void + { + /** @var Request|null */ + $captured = null; + $transport = new MockTransport(static function (Request $req) use (&$captured): \FmsOData\Http\Response { + $captured = $req; + + return ResponseFactory::json(200, ['scriptResult' => ['code' => 0, 'resultParameter' => 'ok']]); + }); + $client = $this->makeClient($transport); + + $client->from('Contacts')->byKey(7)->script('Ping'); + + self::assertNotNull($captured); + self::assertSame(self::BASE . '/Contacts(7)/Script.Ping', $captured->url); + } + + public function testParseAndRaiseZeroCodeReturnsResult(): void + { + $result = ScriptInvoker::parseAndRaise( + ['scriptResult' => ['code' => 0, 'resultParameter' => 'ok']], + self::BASE . '/Script.Ping', + ); + self::assertSame(0, $result->code); + self::assertSame('ok', $result->resultParameter); + } + + public function testParseAndRaiseNonZeroThrows(): void + { + $this->expectException(FMScriptError::class); + ScriptInvoker::parseAndRaise( + ['scriptResult' => ['code' => 101, 'resultParameter' => '']], + self::BASE . '/Script.Ping', + ); + } +} diff --git a/tests/Support/ResponseFactory.php b/tests/Support/ResponseFactory.php index 97a26f5..3464949 100644 --- a/tests/Support/ResponseFactory.php +++ b/tests/Support/ResponseFactory.php @@ -45,6 +45,16 @@ public static function noContent(int $status = 204): Response return new Response($status, [], ''); } + /** + * @param array $headers + */ + public static function binary(int $status, string $body, array $headers = []): Response + { + $headers = \array_merge(['Content-Type' => 'application/octet-stream'], $headers); + + return new Response($status, self::normalizeHeaders($headers), $body); + } + /** * @param array $headers */