diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2509fdf..71a6437 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,14 @@ name: CI on: push: - branches-ignore: - - 'generated' - - 'codegen/**' - - 'integrated/**' - - 'stl-preview-head/**' - - 'stl-preview-base/**' + branches: + - '**' + - '!integrated/**' + - '!stl-preview-head/**' + - '!stl-preview-base/**' + - '!generated' + - '!codegen/**' + - 'codegen/stl/**' pull_request: branches-ignore: - 'stl-preview-head/**' diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 1332969..3d2ac0b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.0.1" + ".": "0.1.0" } \ No newline at end of file diff --git a/.stats.yml b/.stats.yml index 8ec4701..229f6b5 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 23 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-774bb08472b6bb14c280fe5b767925675516b5c8ccc0b89b5abd7ac7bc30fe5a.yml -openapi_spec_hash: ddd1ce1f334b45206ac008b0f5296842 -config_hash: b5ac0c1579dfe6257bcdb84cfd1002fc +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/beeper%2Fbeeper-desktop-api-611aa7641fbca8cf31d626bf86f9efd3c2b92778e897ebbb25c6ea44185ed1ed.yml +openapi_spec_hash: d6c0a1776048dab04f6c5625c9893c9c +config_hash: 39ed0717b5f415499aaace2468346e1a diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..37549a4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +## 0.1.0 (2026-04-18) + +Full Changelog: [v0.0.1...v0.1.0](https://github.com/beeper/desktop-api-php/compare/v0.0.1...v0.1.0) + +### Features + +* **api:** add network, bridge fields to accounts ([8b25044](https://github.com/beeper/desktop-api-php/commit/8b250446d8d538fc1027c9df483f07034049e302)) +* **api:** api update ([e098615](https://github.com/beeper/desktop-api-php/commit/e0986159d91fbd76d8647de9e2f7e2f1aedb071f)) +* **api:** api update ([fe6b606](https://github.com/beeper/desktop-api-php/commit/fe6b606f1e40643b6f81a68bfbf467653d070a6a)) +* **api:** api update ([f813329](https://github.com/beeper/desktop-api-php/commit/f8133290552840e3723a0f67371a4481039c5ac6)) +* **api:** api update ([1c754b3](https://github.com/beeper/desktop-api-php/commit/1c754b319cf35db0357cd97d9713119b78f70fc5)) +* **api:** manual updates ([46dbc09](https://github.com/beeper/desktop-api-php/commit/46dbc095defeac9aa95e41cd668e883fcd20bea9)) +* **api:** update via SDK Studio ([1c4d0e7](https://github.com/beeper/desktop-api-php/commit/1c4d0e7cb265c3bfcca18b886ec84d25eb7154d2)) +* **api:** update via SDK Studio ([e372296](https://github.com/beeper/desktop-api-php/commit/e372296e9885f6bb33819a22d29e92ac725395f1)) + + +### Bug Fixes + +* **client:** properly generate file params ([44ea2d6](https://github.com/beeper/desktop-api-php/commit/44ea2d6f7210b88dd61b764242e7a68254f07efb)) +* **client:** resolve serialization issue with unions and enums ([7d947c2](https://github.com/beeper/desktop-api-php/commit/7d947c2190514f5039947136a1e795e69069d42b)) +* populate enum-typed properties with enum instances ([8c3b6fc](https://github.com/beeper/desktop-api-php/commit/8c3b6fc3be27e61cfb5556fab51f29bff5eae2e6)) + + +### Chores + +* **internal:** codegen related update ([297da59](https://github.com/beeper/desktop-api-php/commit/297da598fb4ba18fec57a63e461e4316f3d6e641)) +* **internal:** tweak CI branches ([d3967d0](https://github.com/beeper/desktop-api-php/commit/d3967d0433e63e905f0d9699da709a7544ec12db)) +* **internal:** update multipart form array serialization ([05282bb](https://github.com/beeper/desktop-api-php/commit/05282bbbaad46dceff167998b75e34a778f42a24)) +* **internal:** upgrade phpunit ([958db57](https://github.com/beeper/desktop-api-php/commit/958db5719fcca4f33c8b9c4796e34b8a3c5a8d91)) +* **test:** do not count install time for mock server timeout ([344cf76](https://github.com/beeper/desktop-api-php/commit/344cf76b4dc4c254d6c380f035e8b6d2d86fb138)) +* **tests:** bump steady to v0.19.4 ([5ead500](https://github.com/beeper/desktop-api-php/commit/5ead500c3d450d125dee608bc6ec0054e6928e15)) +* **tests:** bump steady to v0.19.5 ([1987ab0](https://github.com/beeper/desktop-api-php/commit/1987ab0fcc91f0bebfdf55a94fd5b6c59f3658e3)) +* **tests:** bump steady to v0.19.6 ([5299bea](https://github.com/beeper/desktop-api-php/commit/5299bea39412364752a5a21800bc9e5837cbce81)) +* **tests:** bump steady to v0.19.7 ([356dd2b](https://github.com/beeper/desktop-api-php/commit/356dd2b76fe8549a90e60013ad72e3fd5683e3cd)) +* **tests:** bump steady to v0.20.1 ([9a809f7](https://github.com/beeper/desktop-api-php/commit/9a809f782c368dbed079b96555a27f76f39a9b42)) +* **tests:** bump steady to v0.20.2 ([71daf35](https://github.com/beeper/desktop-api-php/commit/71daf3588b2f471df63c4070b121e8c5587e5727)) +* **tests:** bump steady to v0.22.1 ([192de54](https://github.com/beeper/desktop-api-php/commit/192de547707b52e2d5f60984af9d2c153bef2060)) + + +### Refactors + +* **tests:** switch from prism to steady ([b2a0994](https://github.com/beeper/desktop-api-php/commit/b2a099467ced88b63d36ae05aa4db7da5b5c64c8)) diff --git a/README.md b/README.md index b5d91b8..e90f056 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,36 @@ $client = new Client(requestOptions: ['maxRetries' => 0]); $result = $client->accounts->list(requestOptions: ['maxRetries' => 5]); ``` +### File uploads + +Request parameters that correspond to file uploads can be passed as a resource returned by `fopen()`, a string of file contents, or a `FileParam` instance. + +```php +assets->upload( + file: FileParam::fromString($contents, filename: '/path/to/file', contentType: '…'), +); + +// Pass in only a string (where applicable) +$response = $client->assets->upload(file: '…'); + +// Pass an open resource: +$fd = fopen('/path/to/file', 'r'); +try { + $response = $client->assets->upload( + file: FileParam::fromResource($fd, filename: '/path/to/file', contentType: '…'), + ); +} finally { + fclose($fd); +} +``` + ## Advanced concepts ### Making custom or undocumented requests diff --git a/composer.lock b/composer.lock index 3f6c209..7a5e63d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "5fc63f7c84d94b42416689723c547f69", + "content-hash": "ffa287ea8babf60e021f37e62c6c207a", "packages": [ { "name": "php-http/discovery", @@ -194,16 +194,16 @@ "packages-dev": [ { "name": "brianium/paratest", - "version": "v7.8.4", + "version": "v7.8.5", "source": { "type": "git", "url": "https://github.com/paratestphp/paratest.git", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4" + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/130a9bf0e269ee5f5b320108f794ad03e275cad4", - "reference": "130a9bf0e269ee5f5b320108f794ad03e275cad4", + "url": "https://api.github.com/repos/paratestphp/paratest/zipball/9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", + "reference": "9b324c8fc319cf9728b581c7a90e1c8f6361c5e5", "shasum": "" }, "require": { @@ -211,27 +211,27 @@ "ext-pcre": "*", "ext-reflection": "*", "ext-simplexml": "*", - "fidry/cpu-core-counter": "^1.2.0", + "fidry/cpu-core-counter": "^1.3.0", "jean85/pretty-package-versions": "^2.1.1", - "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "phpunit/php-code-coverage": "^11.0.10", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-timer": "^7.0.1", - "phpunit/phpunit": "^11.5.24", + "phpunit/phpunit": "^11.5.46", "sebastian/environment": "^7.2.1", - "symfony/console": "^6.4.22 || ^7.3.0", - "symfony/process": "^6.4.20 || ^7.3.0" + "symfony/console": "^6.4.22 || ^7.3.4 || ^8.0.3", + "symfony/process": "^6.4.20 || ^7.3.4 || ^8.0.3" }, "require-dev": { "doctrine/coding-standard": "^12.0.0", "ext-pcov": "*", "ext-posix": "*", - "phpstan/phpstan": "^2.1.17", + "phpstan/phpstan": "^2.1.33", "phpstan/phpstan-deprecation-rules": "^2.0.3", - "phpstan/phpstan-phpunit": "^2.0.6", - "phpstan/phpstan-strict-rules": "^2.0.4", - "squizlabs/php_codesniffer": "^3.13.2", - "symfony/filesystem": "^6.4.13 || ^7.3.0" + "phpstan/phpstan-phpunit": "^2.0.11", + "phpstan/phpstan-strict-rules": "^2.0.7", + "squizlabs/php_codesniffer": "^3.13.5", + "symfony/filesystem": "^6.4.13 || ^7.3.2 || ^8.0.1" }, "bin": [ "bin/paratest", @@ -271,7 +271,7 @@ ], "support": { "issues": "https://github.com/paratestphp/paratest/issues", - "source": "https://github.com/paratestphp/paratest/tree/v7.8.4" + "source": "https://github.com/paratestphp/paratest/tree/v7.8.5" }, "funding": [ { @@ -283,7 +283,7 @@ "type": "paypal" } ], - "time": "2025-06-23T06:07:21+00:00" + "time": "2026-01-08T08:02:38+00:00" }, { "name": "clue/ndjson-react", @@ -1412,38 +1412,38 @@ }, { "name": "pestphp/pest", - "version": "v3.8.4", + "version": "v3.8.5", "source": { "type": "git", "url": "https://github.com/pestphp/pest.git", - "reference": "72cf695554420e21858cda831d5db193db102574" + "reference": "7796630eafcfd1c02660cecdde3bc6984fbf01f4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pestphp/pest/zipball/72cf695554420e21858cda831d5db193db102574", - "reference": "72cf695554420e21858cda831d5db193db102574", + "url": "https://api.github.com/repos/pestphp/pest/zipball/7796630eafcfd1c02660cecdde3bc6984fbf01f4", + "reference": "7796630eafcfd1c02660cecdde3bc6984fbf01f4", "shasum": "" }, "require": { - "brianium/paratest": "^7.8.4", - "nunomaduro/collision": "^8.8.2", - "nunomaduro/termwind": "^2.3.1", + "brianium/paratest": "^7.8.5", + "nunomaduro/collision": "^8.8.3", + "nunomaduro/termwind": "^2.3.3", "pestphp/pest-plugin": "^3.0.0", "pestphp/pest-plugin-arch": "^3.1.1", "pestphp/pest-plugin-mutate": "^3.0.5", "php": "^8.2.0", - "phpunit/phpunit": "^11.5.33" + "phpunit/phpunit": "^11.5.50" }, "conflict": { "filp/whoops": "<2.16.0", - "phpunit/phpunit": ">11.5.33", + "phpunit/phpunit": ">11.5.50", "sebastian/exporter": "<6.0.0", "webmozart/assert": "<1.11.0" }, "require-dev": { "pestphp/pest-dev-tools": "^3.4.0", "pestphp/pest-plugin-type-coverage": "^3.6.1", - "symfony/process": "^7.3.0" + "symfony/process": "^7.4.4" }, "bin": [ "bin/pest" @@ -1508,7 +1508,7 @@ ], "support": { "issues": "https://github.com/pestphp/pest/issues", - "source": "https://github.com/pestphp/pest/tree/v3.8.4" + "source": "https://github.com/pestphp/pest/tree/v3.8.5" }, "funding": [ { @@ -1520,7 +1520,7 @@ "type": "github" } ], - "time": "2025-08-20T19:12:42+00:00" + "time": "2026-01-28T01:33:45+00:00" }, { "name": "pestphp/pest-plugin", @@ -2627,28 +2627,28 @@ }, { "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "version": "5.1.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903", + "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -2676,15 +2676,27 @@ "support": { "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1" }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2026-02-02T13:52:54+00:00" }, { "name": "phpunit/php-invoker", @@ -2872,16 +2884,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.33", + "version": "11.5.50", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6" + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", - "reference": "5965e9ff57546cb9137c0ff6aa78cb7442b05cf6", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", + "reference": "fdfc727f0fcacfeb8fcb30c7e5da173125b58be3", "shasum": "" }, "require": { @@ -2895,17 +2907,17 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.10", + "phpunit/php-code-coverage": "^11.0.12", "phpunit/php-file-iterator": "^5.1.0", "phpunit/php-invoker": "^5.0.1", "phpunit/php-text-template": "^4.0.1", "phpunit/php-timer": "^7.0.1", "sebastian/cli-parser": "^3.0.2", "sebastian/code-unit": "^3.0.3", - "sebastian/comparator": "^6.3.2", + "sebastian/comparator": "^6.3.3", "sebastian/diff": "^6.0.2", "sebastian/environment": "^7.2.1", - "sebastian/exporter": "^6.3.0", + "sebastian/exporter": "^6.3.2", "sebastian/global-state": "^7.0.2", "sebastian/object-enumerator": "^6.0.1", "sebastian/type": "^5.1.3", @@ -2953,7 +2965,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.33" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.50" }, "funding": [ { @@ -2977,7 +2989,7 @@ "type": "tidelift" } ], - "time": "2025-08-16T05:19:02+00:00" + "time": "2026-01-27T05:59:18+00:00" }, { "name": "psr/container", @@ -3936,16 +3948,16 @@ }, { "name": "sebastian/comparator", - "version": "6.3.2", + "version": "6.3.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8" + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/85c77556683e6eee4323e4c5468641ca0237e2e8", - "reference": "85c77556683e6eee4323e4c5468641ca0237e2e8", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", + "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9", "shasum": "" }, "require": { @@ -4004,7 +4016,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3" }, "funding": [ { @@ -4024,7 +4036,7 @@ "type": "tidelift" } ], - "time": "2025-08-10T08:07:46+00:00" + "time": "2026-01-24T09:26:40+00:00" }, { "name": "sebastian/complexity", @@ -6668,5 +6680,5 @@ "platform-overrides": { "php": "8.3" }, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/scripts/mock b/scripts/mock index 0b28f6e..9c7c439 100755 --- a/scripts/mock +++ b/scripts/mock @@ -19,23 +19,34 @@ fi echo "==> Starting mock server with URL ${URL}" -# Run prism mock on the given spec +# Run steady mock on the given spec if [ "$1" == "--daemon" ]; then - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" &> .prism.log & + # Pre-install the package so the download doesn't eat into the startup timeout + npm exec --package=@stdy/cli@0.22.1 -- steady --version - # Wait for server to come online + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" &> .stdy.log & + + # Wait for server to come online via health endpoint (max 30s) echo -n "Waiting for server" - while ! grep -q "✖ fatal\|Prism is listening" ".prism.log" ; do + attempts=0 + while ! curl --silent --fail "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1; do + if ! kill -0 $! 2>/dev/null; then + echo + cat .stdy.log + exit 1 + fi + attempts=$((attempts + 1)) + if [ "$attempts" -ge 300 ]; then + echo + echo "Timed out waiting for Steady server to start" + cat .stdy.log + exit 1 + fi echo -n "." sleep 0.1 done - if grep -q "✖ fatal" ".prism.log"; then - cat .prism.log - exit 1 - fi - echo else - npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock "$URL" + npm exec --package=@stdy/cli@0.22.1 -- steady --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets "$URL" fi diff --git a/scripts/test b/scripts/test index 4b777e0..bef4dad 100755 --- a/scripts/test +++ b/scripts/test @@ -9,8 +9,8 @@ GREEN='\033[0;32m' YELLOW='\033[0;33m' NC='\033[0m' # No Color -function prism_is_running() { - curl --silent "http://localhost:4010" >/dev/null 2>&1 +function steady_is_running() { + curl --silent "http://127.0.0.1:4010/_x-steady/health" >/dev/null 2>&1 } kill_server_on_port() { @@ -25,7 +25,7 @@ function is_overriding_api_base_url() { [ -n "$TEST_API_BASE_URL" ] } -if ! is_overriding_api_base_url && ! prism_is_running ; then +if ! is_overriding_api_base_url && ! steady_is_running ; then # When we exit this script, make sure to kill the background mock server process trap 'kill_server_on_port 4010' EXIT @@ -36,19 +36,19 @@ fi if is_overriding_api_base_url ; then echo -e "${GREEN}✔ Running tests against ${TEST_API_BASE_URL}${NC}" echo -elif ! prism_is_running ; then - echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Prism server" +elif ! steady_is_running ; then + echo -e "${RED}ERROR:${NC} The test suite will not run without a mock Steady server" echo -e "running against your OpenAPI spec." echo echo -e "To run the server, pass in the path or url of your OpenAPI" - echo -e "spec to the prism command:" + echo -e "spec to the steady command:" echo - echo -e " \$ ${YELLOW}npm exec --package=@stainless-api/prism-cli@5.15.0 -- prism mock path/to/your.openapi.yml${NC}" + echo -e " \$ ${YELLOW}npm exec --package=@stdy/cli@0.22.1 -- steady path/to/your.openapi.yml --host 127.0.0.1 -p 4010 --validator-query-array-format=repeat --validator-form-array-format=repeat --validator-query-object-format=brackets --validator-form-object-format=brackets${NC}" echo exit 1 else - echo -e "${GREEN}✔ Mock prism server is running with your OpenAPI spec${NC}" + echo -e "${GREEN}✔ Mock steady server is running with your OpenAPI spec${NC}" echo fi diff --git a/src/Accounts/Account.php b/src/Accounts/Account.php index 270cb3e..174a24a 100644 --- a/src/Accounts/Account.php +++ b/src/Accounts/Account.php @@ -4,6 +4,7 @@ namespace BeeperDesktop\Accounts; +use BeeperDesktop\Accounts\Account\Bridge; use BeeperDesktop\Core\Attributes\Required; use BeeperDesktop\Core\Concerns\SdkModel; use BeeperDesktop\Core\Contracts\BaseModel; @@ -12,9 +13,15 @@ /** * A chat account added to Beeper. * + * @phpstan-import-type BridgeShape from \BeeperDesktop\Accounts\Account\Bridge * @phpstan-import-type UserShape from \BeeperDesktop\User * - * @phpstan-type AccountShape = array{accountID: string, user: User|UserShape} + * @phpstan-type AccountShape = array{ + * accountID: string, + * bridge: Bridge|BridgeShape, + * network: string, + * user: User|UserShape, + * } */ final class Account implements BaseModel { @@ -27,6 +34,18 @@ final class Account implements BaseModel #[Required] public string $accountID; + /** + * Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+. + */ + #[Required] + public Bridge $bridge; + + /** + * Human-friendly network name for the account. + */ + #[Required] + public string $network; + /** * User the account belongs to. */ @@ -38,13 +57,17 @@ final class Account implements BaseModel * * To enforce required parameters use * ``` - * Account::with(accountID: ..., user: ...) + * Account::with(accountID: ..., bridge: ..., network: ..., user: ...) * ``` * * Otherwise ensure the following setters are called * * ``` - * (new Account)->withAccountID(...)->withUser(...) + * (new Account) + * ->withAccountID(...) + * ->withBridge(...) + * ->withNetwork(...) + * ->withUser(...) * ``` */ public function __construct() @@ -57,13 +80,20 @@ public function __construct() * * You must use named parameters to construct any parameters with a default value. * + * @param Bridge|BridgeShape $bridge * @param User|UserShape $user */ - public static function with(string $accountID, User|array $user): self - { + public static function with( + string $accountID, + Bridge|array $bridge, + string $network, + User|array $user + ): self { $self = new self; $self['accountID'] = $accountID; + $self['bridge'] = $bridge; + $self['network'] = $network; $self['user'] = $user; return $self; @@ -80,6 +110,30 @@ public function withAccountID(string $accountID): self return $self; } + /** + * Bridge metadata for the account. Available from Beeper Desktop v.4.2.719+. + * + * @param Bridge|BridgeShape $bridge + */ + public function withBridge(Bridge|array $bridge): self + { + $self = clone $this; + $self['bridge'] = $bridge; + + return $self; + } + + /** + * Human-friendly network name for the account. + */ + public function withNetwork(string $network): self + { + $self = clone $this; + $self['network'] = $network; + + return $self; + } + /** * User the account belongs to. * diff --git a/src/Accounts/Account/Bridge.php b/src/Accounts/Account/Bridge.php new file mode 100644 index 0000000..73b4105 --- /dev/null +++ b/src/Accounts/Account/Bridge.php @@ -0,0 +1,118 @@ +, type: string + * } + */ +final class Bridge implements BaseModel +{ + /** @use SdkModel */ + use SdkModel; + + /** + * Bridge instance identifier. + */ + #[Required] + public string $id; + + /** + * Bridge provider for the account. + * + * @var value-of $provider + */ + #[Required(enum: Provider::class)] + public string $provider; + + /** + * Bridge type. + */ + #[Required] + public string $type; + + /** + * `new Bridge()` is missing required properties by the API. + * + * To enforce required parameters use + * ``` + * Bridge::with(id: ..., provider: ..., type: ...) + * ``` + * + * Otherwise ensure the following setters are called + * + * ``` + * (new Bridge)->withID(...)->withProvider(...)->withType(...) + * ``` + */ + public function __construct() + { + $this->initialize(); + } + + /** + * Construct an instance from the required parameters. + * + * You must use named parameters to construct any parameters with a default value. + * + * @param Provider|value-of $provider + */ + public static function with( + string $id, + Provider|string $provider, + string $type + ): self { + $self = new self; + + $self['id'] = $id; + $self['provider'] = $provider; + $self['type'] = $type; + + return $self; + } + + /** + * Bridge instance identifier. + */ + public function withID(string $id): self + { + $self = clone $this; + $self['id'] = $id; + + return $self; + } + + /** + * Bridge provider for the account. + * + * @param Provider|value-of $provider + */ + public function withProvider(Provider|string $provider): self + { + $self = clone $this; + $self['provider'] = $provider; + + return $self; + } + + /** + * Bridge type. + */ + public function withType(string $type): self + { + $self = clone $this; + $self['type'] = $type; + + return $self; + } +} diff --git a/src/Accounts/Account/Bridge/Provider.php b/src/Accounts/Account/Bridge/Provider.php new file mode 100644 index 0000000..59ca678 --- /dev/null +++ b/src/Accounts/Account/Bridge/Provider.php @@ -0,0 +1,19 @@ +withChat(...) - * ``` - */ public function __construct() { $this->initialize(); @@ -54,26 +41,25 @@ public function __construct() * * You must use named parameters to construct any parameters with a default value. * - * @param Chat|ChatShape $chat + * @param ParamsShape|null $params */ public static function with( - Chat|array $chat + UnionMember0|array|UnionMember1|null $params = null ): self { $self = new self; - $self['chat'] = $chat; + null !== $params && $self['params'] = $params; return $self; } /** - * @param Chat|ChatShape $chat + * @param ParamsShape $params */ - public function withChat( - Chat|array $chat - ): self { + public function withParams(UnionMember0|array|UnionMember1 $params): self + { $self = clone $this; - $self['chat'] = $chat; + $self['params'] = $params; return $self; } diff --git a/src/Chats/ChatCreateParams/Chat/Type.php b/src/Chats/ChatCreateParams/Chat/Type.php deleted file mode 100644 index 630c2f6..0000000 --- a/src/Chats/ChatCreateParams/Chat/Type.php +++ /dev/null @@ -1,15 +0,0 @@ -|array + */ + public static function variants(): array + { + return [UnionMember0::class, UnionMember1::class]; + } +} diff --git a/src/Chats/ChatCreateParams/Params/UnionMember0.php b/src/Chats/ChatCreateParams/Params/UnionMember0.php new file mode 100644 index 0000000..21194f4 --- /dev/null +++ b/src/Chats/ChatCreateParams/Params/UnionMember0.php @@ -0,0 +1,166 @@ +, + * user: User|UserShape, + * allowInvite?: bool|null, + * messageText?: string|null, + * } + */ +final class UnionMember0 implements BaseModel +{ + /** @use SdkModel */ + use SdkModel; + + /** + * Account to create or start the chat on. + */ + #[Required] + public string $accountID; + + /** + * Operation mode. Use 'start' to resolve a user/contact and start a direct chat. + * + * @var value-of $mode + */ + #[Required(enum: Mode::class)] + public string $mode; + + /** + * Merged user-like contact payload used to resolve the best identifier. + */ + #[Required] + public User $user; + + /** + * Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'. + */ + #[Optional] + public ?bool $allowInvite; + + /** + * Optional first message content if the platform requires it to create the chat. + */ + #[Optional] + public ?string $messageText; + + /** + * `new UnionMember0()` is missing required properties by the API. + * + * To enforce required parameters use + * ``` + * UnionMember0::with(accountID: ..., mode: ..., user: ...) + * ``` + * + * Otherwise ensure the following setters are called + * + * ``` + * (new UnionMember0)->withAccountID(...)->withMode(...)->withUser(...) + * ``` + */ + public function __construct() + { + $this->initialize(); + } + + /** + * Construct an instance from the required parameters. + * + * You must use named parameters to construct any parameters with a default value. + * + * @param Mode|value-of $mode + * @param User|UserShape $user + */ + public static function with( + string $accountID, + Mode|string $mode, + User|array $user, + ?bool $allowInvite = null, + ?string $messageText = null, + ): self { + $self = new self; + + $self['accountID'] = $accountID; + $self['mode'] = $mode; + $self['user'] = $user; + + null !== $allowInvite && $self['allowInvite'] = $allowInvite; + null !== $messageText && $self['messageText'] = $messageText; + + return $self; + } + + /** + * Account to create or start the chat on. + */ + public function withAccountID(string $accountID): self + { + $self = clone $this; + $self['accountID'] = $accountID; + + return $self; + } + + /** + * Operation mode. Use 'start' to resolve a user/contact and start a direct chat. + * + * @param Mode|value-of $mode + */ + public function withMode(Mode|string $mode): self + { + $self = clone $this; + $self['mode'] = $mode; + + return $self; + } + + /** + * Merged user-like contact payload used to resolve the best identifier. + * + * @param User|UserShape $user + */ + public function withUser(User|array $user): self + { + $self = clone $this; + $self['user'] = $user; + + return $self; + } + + /** + * Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'. + */ + public function withAllowInvite(bool $allowInvite): self + { + $self = clone $this; + $self['allowInvite'] = $allowInvite; + + return $self; + } + + /** + * Optional first message content if the platform requires it to create the chat. + */ + public function withMessageText(string $messageText): self + { + $self = clone $this; + $self['messageText'] = $messageText; + + return $self; + } +} diff --git a/src/Chats/ChatCreateParams/Params/UnionMember0/Mode.php b/src/Chats/ChatCreateParams/Params/UnionMember0/Mode.php new file mode 100644 index 0000000..6fd34d7 --- /dev/null +++ b/src/Chats/ChatCreateParams/Params/UnionMember0/Mode.php @@ -0,0 +1,13 @@ +, + * type: Type|value-of, * messageText?: string|null, * mode?: null|Mode|value-of, - * participantIDs?: list|null, * title?: string|null, - * type?: null|Type|value-of, - * user?: null|User|UserShape, * } */ -final class Chat implements BaseModel +final class UnionMember1 implements BaseModel { - /** @use SdkModel */ + /** @use SdkModel */ use SdkModel; /** @@ -38,10 +33,20 @@ final class Chat implements BaseModel public string $accountID; /** - * Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'. + * User IDs to include in the new chat. + * + * @var list $participantIDs */ - #[Optional] - public ?bool $allowInvite; + #[Required(list: 'string')] + public array $participantIDs; + + /** + * 'single' requires exactly one participantID; 'group' supports multiple participants and optional title. + * + * @var value-of $type + */ + #[Required(enum: Type::class)] + public string $type; /** * Optional first message content if the platform requires it to create the chat. @@ -58,45 +63,23 @@ final class Chat implements BaseModel public ?string $mode; /** - * Required when mode='create'. User IDs to include in the new chat. - * - * @var list|null $participantIDs - */ - #[Optional(list: 'string')] - public ?array $participantIDs; - - /** - * Optional title for group chats when mode='create'; ignored for single chats on most platforms. + * Optional title for group chats; ignored for single chats on most platforms. */ #[Optional] public ?string $title; /** - * Required when mode='create'. 'single' requires exactly one participantID; 'group' supports multiple participants and optional title. - * - * @var value-of|null $type - */ - #[Optional(enum: Type::class)] - public ?string $type; - - /** - * Required when mode='start'. Merged user-like contact payload used to resolve the best identifier. - */ - #[Optional] - public ?User $user; - - /** - * `new Chat()` is missing required properties by the API. + * `new UnionMember1()` is missing required properties by the API. * * To enforce required parameters use * ``` - * Chat::with(accountID: ...) + * UnionMember1::with(accountID: ..., participantIDs: ..., type: ...) * ``` * * Otherwise ensure the following setters are called * * ``` - * (new Chat)->withAccountID(...) + * (new UnionMember1)->withAccountID(...)->withParticipantIDs(...)->withType(...) * ``` */ public function __construct() @@ -109,32 +92,27 @@ public function __construct() * * You must use named parameters to construct any parameters with a default value. * + * @param list $participantIDs + * @param Type|value-of $type * @param Mode|value-of|null $mode - * @param list|null $participantIDs - * @param Type|value-of|null $type - * @param User|UserShape|null $user */ public static function with( string $accountID, - ?bool $allowInvite = null, + array $participantIDs, + Type|string $type, ?string $messageText = null, Mode|string|null $mode = null, - ?array $participantIDs = null, ?string $title = null, - Type|string|null $type = null, - User|array|null $user = null, ): self { $self = new self; $self['accountID'] = $accountID; + $self['participantIDs'] = $participantIDs; + $self['type'] = $type; - null !== $allowInvite && $self['allowInvite'] = $allowInvite; null !== $messageText && $self['messageText'] = $messageText; null !== $mode && $self['mode'] = $mode; - null !== $participantIDs && $self['participantIDs'] = $participantIDs; null !== $title && $self['title'] = $title; - null !== $type && $self['type'] = $type; - null !== $user && $self['user'] = $user; return $self; } @@ -151,86 +129,62 @@ public function withAccountID(string $accountID): self } /** - * Whether invite-based DM creation is allowed when required by the platform. Used for mode='start'. - */ - public function withAllowInvite(bool $allowInvite): self - { - $self = clone $this; - $self['allowInvite'] = $allowInvite; - - return $self; - } - - /** - * Optional first message content if the platform requires it to create the chat. - */ - public function withMessageText(string $messageText): self - { - $self = clone $this; - $self['messageText'] = $messageText; - - return $self; - } - - /** - * Operation mode. Defaults to 'create' when omitted. + * User IDs to include in the new chat. * - * @param Mode|value-of $mode + * @param list $participantIDs */ - public function withMode(Mode|string $mode): self + public function withParticipantIDs(array $participantIDs): self { $self = clone $this; - $self['mode'] = $mode; + $self['participantIDs'] = $participantIDs; return $self; } /** - * Required when mode='create'. User IDs to include in the new chat. + * 'single' requires exactly one participantID; 'group' supports multiple participants and optional title. * - * @param list $participantIDs + * @param Type|value-of $type */ - public function withParticipantIDs(array $participantIDs): self + public function withType(Type|string $type): self { $self = clone $this; - $self['participantIDs'] = $participantIDs; + $self['type'] = $type; return $self; } /** - * Optional title for group chats when mode='create'; ignored for single chats on most platforms. + * Optional first message content if the platform requires it to create the chat. */ - public function withTitle(string $title): self + public function withMessageText(string $messageText): self { $self = clone $this; - $self['title'] = $title; + $self['messageText'] = $messageText; return $self; } /** - * Required when mode='create'. 'single' requires exactly one participantID; 'group' supports multiple participants and optional title. + * Operation mode. Defaults to 'create' when omitted. * - * @param Type|value-of $type + * @param Mode|value-of $mode */ - public function withType(Type|string $type): self + public function withMode(Mode|string $mode): self { $self = clone $this; - $self['type'] = $type; + $self['mode'] = $mode; return $self; } /** - * Required when mode='start'. Merged user-like contact payload used to resolve the best identifier. - * - * @param User|UserShape $user + * Optional title for group chats; ignored for single chats on most platforms. */ - public function withUser(User|array $user): self + public function withTitle(string $title): self { $self = clone $this; - $self['user'] = $user; + $self['title'] = $title; return $self; } diff --git a/src/Chats/ChatCreateParams/Chat/Mode.php b/src/Chats/ChatCreateParams/Params/UnionMember1/Mode.php similarity index 64% rename from src/Chats/ChatCreateParams/Chat/Mode.php rename to src/Chats/ChatCreateParams/Params/UnionMember1/Mode.php index ce0dabd..bd342be 100644 --- a/src/Chats/ChatCreateParams/Chat/Mode.php +++ b/src/Chats/ChatCreateParams/Params/UnionMember1/Mode.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace BeeperDesktop\Chats\ChatCreateParams\Chat; +namespace BeeperDesktop\Chats\ChatCreateParams\Params\UnionMember1; /** * Operation mode. Defaults to 'create' when omitted. @@ -10,6 +10,4 @@ enum Mode: string { case CREATE = 'create'; - - case START = 'start'; } diff --git a/src/Chats/ChatCreateParams/Params/UnionMember1/Type.php b/src/Chats/ChatCreateParams/Params/UnionMember1/Type.php new file mode 100644 index 0000000..fe89d65 --- /dev/null +++ b/src/Chats/ChatCreateParams/Params/UnionMember1/Type.php @@ -0,0 +1,15 @@ + */ - private static array $enumConverters = []; - /** * @param class-string|Converter|string|null $type * @param class-string<\BackedEnum>|Converter|null $enum @@ -52,7 +49,7 @@ public function __construct( $type ??= new MapOf($map); } if (null !== $enum) { - $type ??= $enum instanceof Converter ? $enum : self::enumConverter($enum); + $type ??= $enum instanceof Converter ? $enum : EnumOf::fromBackedEnum($enum); } $this->apiName = $apiName; @@ -60,16 +57,4 @@ public function __construct( $this->optional = false; $this->nullable = $nullable; } - - /** @property class-string<\BackedEnum> $enum */ - private static function enumConverter(string $enum): Converter - { - if (!isset(self::$enumConverters[$enum])) { - // @phpstan-ignore-next-line argument.type - $converter = new EnumOf(array_column($enum::cases(), column_key: 'value')); - self::$enumConverters[$enum] = $converter; - } - - return self::$enumConverters[$enum]; - } } diff --git a/src/Core/Conversion.php b/src/Core/Conversion.php index 494ec69..84a6f56 100644 --- a/src/Core/Conversion.php +++ b/src/Core/Conversion.php @@ -8,6 +8,7 @@ use BeeperDesktop\Core\Conversion\Contracts\Converter; use BeeperDesktop\Core\Conversion\Contracts\ConverterSource; use BeeperDesktop\Core\Conversion\DumpState; +use BeeperDesktop\Core\Conversion\EnumOf; /** * @internal @@ -21,6 +22,10 @@ public static function dump_unknown(mixed $value, DumpState $state): mixed } if (is_object($value)) { + if ($value instanceof FileParam) { + return $value; + } + if (is_a($value, class: ConverterSource::class)) { return $value::converter()->dump($value, state: $state); } @@ -61,6 +66,13 @@ public static function coerce(Converter|ConverterSource|string $target, mixed $v return $target->coerce($value, state: $state); } + // BackedEnum class-name targets: wrap in EnumOf so enum values are scored + // against the enum's cases. Without this, tryConvert's default case scores + // any class-name target as `no`, even when the value is a valid enum member. + if (is_a($target, class: \BackedEnum::class, allow_string: true)) { + return EnumOf::fromBackedEnum($target)->coerce($value, state: $state); + } + return self::tryConvert($target, value: $value, state: $state); } @@ -74,6 +86,13 @@ public static function dump(Converter|ConverterSource|string $target, mixed $val return $target::converter()->dump($value, state: $state); } + // BackedEnum class-name targets: wrap in EnumOf so enum values are scored + // against the enum's cases. Without this, tryConvert's default case scores + // any class-name target as `no`, even when the value is a valid enum member. + if (is_a($target, class: \BackedEnum::class, allow_string: true)) { + return EnumOf::fromBackedEnum($target)->dump($value, state: $state); + } + self::tryConvert($target, value: $value, state: $state); return self::dump_unknown($value, state: $state); @@ -170,6 +189,37 @@ private static function tryConvert(Converter|ConverterSource|string $target, mix return $value; + case 'DateTimeInterface': + case 'DateTimeImmutable': + if (is_string($value)) { + try { + ++$state->maybe; + + return new \DateTimeImmutable($value); + } catch (\Exception) { + --$state->maybe; + } + } + + ++$state->no; + + return $value; + + case 'DateTime': + if (is_string($value)) { + try { + ++$state->maybe; + + return new \DateTime($value); + } catch (\Exception) { + --$state->maybe; + } + } + + ++$state->no; + + return $value; + default: ++$state->no; diff --git a/src/Core/Conversion/EnumOf.php b/src/Core/Conversion/EnumOf.php index a4d791b..c8ce88b 100644 --- a/src/Core/Conversion/EnumOf.php +++ b/src/Core/Conversion/EnumOf.php @@ -14,11 +14,17 @@ final class EnumOf implements Converter { private readonly string $type; + /** @var array, self> */ + private static array $cache = []; + /** * @param list $members + * @param class-string<\BackedEnum>|null $class */ - public function __construct(private readonly array $members) - { + public function __construct( + private readonly array $members, + private readonly ?string $class = null, + ) { $type = 'NULL'; foreach ($this->members as $member) { $type = gettype($member); @@ -26,10 +32,28 @@ public function __construct(private readonly array $members) $this->type = $type; } + /** @param class-string<\BackedEnum> $enum */ + public static function fromBackedEnum(string $enum): self + { + // @phpstan-ignore-next-line argument.type + return self::$cache[$enum] ??= new self( + array_column($enum::cases(), column_key: 'value'), + class: $enum, + ); + } + public function coerce(mixed $value, CoerceState $state): mixed { $this->tally($value, state: $state); + if ($value instanceof \BackedEnum) { + return $value; + } + + if (null !== $this->class && (is_int($value) || is_string($value))) { + return ($this->class)::tryFrom($value) ?? $value; + } + return $value; } @@ -42,9 +66,10 @@ public function dump(mixed $value, DumpState $state): mixed private function tally(mixed $value, CoerceState|DumpState $state): void { - if (in_array($value, haystack: $this->members, strict: true)) { + $needle = $value instanceof \BackedEnum ? $value->value : $value; + if (in_array($needle, haystack: $this->members, strict: true)) { ++$state->yes; - } elseif ($this->type === gettype($value)) { + } elseif ($this->type === gettype($needle)) { ++$state->maybe; } else { ++$state->no; diff --git a/src/Core/FileParam.php b/src/Core/FileParam.php new file mode 100644 index 0000000..eb8db4a --- /dev/null +++ b/src/Core/FileParam.php @@ -0,0 +1,63 @@ +files->upload(file: FileParam::fromResource(fopen('data.csv', 'r'))); + * + * // From a string: + * $client->files->upload(file: FileParam::fromString('csv data...', 'data.csv')); + * ``` + */ +final class FileParam +{ + public const DEFAULT_CONTENT_TYPE = 'application/octet-stream'; + + /** + * @param resource|string $data the file content as a resource or string + */ + private function __construct( + public readonly mixed $data, + public readonly string $filename, + public readonly string $contentType = self::DEFAULT_CONTENT_TYPE, + ) {} + + /** + * Create a FileParam from an open resource (e.g. from fopen()). + * + * @param resource $resource an open file resource + * @param string|null $filename Override the filename. Defaults to the resource URI basename. + * @param string $contentType override the content type + */ + public static function fromResource(mixed $resource, ?string $filename = null, string $contentType = self::DEFAULT_CONTENT_TYPE): self + { + if (!is_resource($resource)) { + throw new \InvalidArgumentException('Expected a resource, got '.get_debug_type($resource)); + } + + if (null === $filename) { + $meta = stream_get_meta_data($resource); + $filename = basename($meta['uri'] ?? 'upload'); + } + + return new self($resource, filename: $filename, contentType: $contentType); + } + + /** + * Create a FileParam from a string. + * + * @param string $content the file content + * @param string $filename the filename for the Content-Disposition header + * @param string $contentType override the content type + */ + public static function fromString(string $content, string $filename, string $contentType = self::DEFAULT_CONTENT_TYPE): self + { + return new self($content, filename: $filename, contentType: $contentType); + } +} diff --git a/src/Core/Util.php b/src/Core/Util.php index 35a9a0b..b385aff 100644 --- a/src/Core/Util.php +++ b/src/Core/Util.php @@ -283,7 +283,7 @@ public static function withSetBody( if (preg_match('/^multipart\/form-data/', $contentType)) { [$boundary, $gen] = self::encodeMultipartStreaming($body); - $encoded = implode('', iterator_to_array($gen)); + $encoded = implode('', iterator_to_array($gen, preserve_keys: false)); $stream = $factory->createStream($encoded); /** @var RequestInterface */ @@ -447,11 +447,18 @@ private static function writeMultipartContent( ): \Generator { $contentLine = "Content-Type: %s\r\n\r\n"; - if (is_resource($val)) { - yield sprintf($contentLine, $contentType ?? 'application/octet-stream'); - while (!feof($val)) { - if ($read = fread($val, length: self::BUF_SIZE)) { - yield $read; + if ($val instanceof FileParam) { + $ct = $val->contentType ?? $contentType; + + yield sprintf($contentLine, $ct); + $data = $val->data; + if (is_string($data)) { + yield $data; + } else { // resource + while (!feof($data)) { + if ($read = fread($data, length: self::BUF_SIZE)) { + yield $read; + } } } } elseif (is_string($val) || is_numeric($val) || is_bool($val)) { @@ -483,17 +490,48 @@ private static function writeMultipartChunk( yield 'Content-Disposition: form-data'; if (!is_null($key)) { - $name = rawurlencode(self::strVal($key)); + $name = str_replace(['"', "\r", "\n"], replace: '', subject: $key); yield "; name=\"{$name}\""; } + // File uploads require a filename in the Content-Disposition header, + // e.g. `Content-Disposition: form-data; name="file"; filename="data.csv"` + // Without this, many servers will reject the upload with a 400. + if ($val instanceof FileParam) { + $filename = str_replace(['"', "\r", "\n"], replace: '', subject: $val->filename); + + yield "; filename=\"{$filename}\""; + } + yield "\r\n"; foreach (self::writeMultipartContent($val, closing: $closing) as $chunk) { yield $chunk; } } + /** + * Expands list arrays into separate multipart parts, applying the configured array key format. + * + * @param list $closing + * + * @return \Generator + */ + private static function writeMultipartField( + string $boundary, + ?string $key, + mixed $val, + array &$closing + ): \Generator { + if (is_array($val) && array_is_list($val)) { + foreach ($val as $item) { + yield from self::writeMultipartField(boundary: $boundary, key: $key, val: $item, closing: $closing); + } + } else { + yield from self::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing); + } + } + /** * @param bool|int|float|string|resource|\Traversable|array|null $body * @@ -508,14 +546,10 @@ private static function encodeMultipartStreaming(mixed $body): array try { if (is_array($body) || is_object($body)) { foreach ((array) $body as $key => $val) { - foreach (static::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing) as $chunk) { - yield $chunk; - } + yield from static::writeMultipartField(boundary: $boundary, key: $key, val: $val, closing: $closing); } } else { - foreach (static::writeMultipartChunk(boundary: $boundary, key: null, val: $body, closing: $closing) as $chunk) { - yield $chunk; - } + yield from static::writeMultipartField(boundary: $boundary, key: null, val: $body, closing: $closing); } yield "--{$boundary}--\r\n"; diff --git a/src/Messages/MessageSearchParams.php b/src/Messages/MessageSearchParams.php index e9ffe25..49d617f 100644 --- a/src/Messages/MessageSearchParams.php +++ b/src/Messages/MessageSearchParams.php @@ -11,7 +11,6 @@ use BeeperDesktop\Messages\MessageSearchParams\ChatType; use BeeperDesktop\Messages\MessageSearchParams\Direction; use BeeperDesktop\Messages\MessageSearchParams\MediaType; -use BeeperDesktop\Messages\MessageSearchParams\Sender; /** * Search messages across chats using Beeper's message index. @@ -31,7 +30,7 @@ * limit?: int|null, * mediaTypes?: list>|null, * query?: string|null, - * sender?: string|null|Sender|value-of, + * sender?: string|null, * } */ final class MessageSearchParams implements BaseModel @@ -124,10 +123,8 @@ final class MessageSearchParams implements BaseModel /** * Filter by sender: 'me' (messages sent by the authenticated user), 'others' (messages sent by others), or a specific user ID string (user.id). - * - * @var string|value-of|null $sender */ - #[Optional(enum: Sender::class)] + #[Optional] public ?string $sender; public function __construct() @@ -145,7 +142,6 @@ public function __construct() * @param ChatType|value-of|null $chatType * @param Direction|value-of|null $direction * @param list>|null $mediaTypes - * @param string|Sender|value-of|null $sender */ public static function with( ?array $accountIDs = null, @@ -160,7 +156,7 @@ public static function with( ?int $limit = null, ?array $mediaTypes = null, ?string $query = null, - Sender|string|null $sender = null, + ?string $sender = null, ): self { $self = new self; @@ -325,10 +321,8 @@ public function withQuery(string $query): self /** * Filter by sender: 'me' (messages sent by the authenticated user), 'others' (messages sent by others), or a specific user ID string (user.id). - * - * @param string|Sender|value-of $sender */ - public function withSender(Sender|string $sender): self + public function withSender(string $sender): self { $self = clone $this; $self['sender'] = $sender; diff --git a/src/Messages/MessageSearchParams/Sender.php b/src/Messages/MessageSearchParams/Sender.php deleted file mode 100644 index e58a8ab..0000000 --- a/src/Messages/MessageSearchParams/Sender.php +++ /dev/null @@ -1,15 +0,0 @@ - + * @return CursorSearch * * @throws APIException */ diff --git a/src/ServiceContracts/MessagesContract.php b/src/ServiceContracts/MessagesContract.php index 886634d..e8c3a3e 100644 --- a/src/ServiceContracts/MessagesContract.php +++ b/src/ServiceContracts/MessagesContract.php @@ -11,7 +11,6 @@ use BeeperDesktop\Messages\MessageListParams\Direction; use BeeperDesktop\Messages\MessageSearchParams\ChatType; use BeeperDesktop\Messages\MessageSearchParams\MediaType; -use BeeperDesktop\Messages\MessageSearchParams\Sender; use BeeperDesktop\Messages\MessageSendParams\Attachment; use BeeperDesktop\Messages\MessageSendResponse; use BeeperDesktop\Messages\MessageUpdateResponse; @@ -74,7 +73,7 @@ public function list( * @param int $limit maximum number of messages to return * @param list> $mediaTypes Filter messages by media types. Use ['any'] for any media type, or specify exact types like ['video', 'image']. Omit for no media filtering. * @param string $query Literal word search (non-semantic). Finds messages containing these EXACT words in any order. Use single words users actually type, not concepts or phrases. Example: use "dinner" not "dinner plans", use "sick" not "health issues". If omitted, returns results filtered only by other parameters. - * @param string|Sender|value-of $sender Filter by sender: 'me' (messages sent by the authenticated user), 'others' (messages sent by others), or a specific user ID string (user.id). + * @param string $sender Filter by sender: 'me' (messages sent by the authenticated user), 'others' (messages sent by others), or a specific user ID string (user.id). * @param RequestOpts|null $requestOptions * * @return CursorSearch @@ -94,7 +93,7 @@ public function search( int $limit = 20, ?array $mediaTypes = null, ?string $query = null, - Sender|string|null $sender = null, + ?string $sender = null, RequestOptions|array|null $requestOptions = null, ): CursorSearch; diff --git a/src/Services/AssetsRawService.php b/src/Services/AssetsRawService.php index 051bf2a..22d415c 100644 --- a/src/Services/AssetsRawService.php +++ b/src/Services/AssetsRawService.php @@ -14,6 +14,7 @@ use BeeperDesktop\Client; use BeeperDesktop\Core\Contracts\BaseResponse; use BeeperDesktop\Core\Exceptions\APIException; +use BeeperDesktop\Core\FileParam; use BeeperDesktop\RequestOptions; use BeeperDesktop\ServiceContracts\AssetsRawContract; @@ -98,7 +99,7 @@ public function serve( * Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending messages with attachments. * * @param array{ - * file: string, fileName?: string, mimeType?: string + * file: string|FileParam, fileName?: string, mimeType?: string * }|AssetUploadParams $params * @param RequestOpts|null $requestOptions * diff --git a/src/Services/AssetsService.php b/src/Services/AssetsService.php index 6d77b41..4027449 100644 --- a/src/Services/AssetsService.php +++ b/src/Services/AssetsService.php @@ -9,6 +9,7 @@ use BeeperDesktop\Assets\AssetUploadResponse; use BeeperDesktop\Client; use BeeperDesktop\Core\Exceptions\APIException; +use BeeperDesktop\Core\FileParam; use BeeperDesktop\Core\Util; use BeeperDesktop\RequestOptions; use BeeperDesktop\ServiceContracts\AssetsContract; @@ -82,7 +83,7 @@ public function serve( * * Upload a file to a temporary location using multipart/form-data. Returns an uploadID that can be referenced when sending messages with attachments. * - * @param string $file the file to upload (max 500 MB) + * @param string|FileParam $file the file to upload (max 500 MB) * @param string $fileName Original filename. Defaults to the uploaded file name if omitted * @param string $mimeType MIME type. Auto-detected from magic bytes if omitted * @param RequestOpts|null $requestOptions @@ -90,7 +91,7 @@ public function serve( * @throws APIException */ public function upload( - string $file, + string|FileParam $file, ?string $fileName = null, ?string $mimeType = null, RequestOptions|array|null $requestOptions = null, diff --git a/src/Services/BeeperDesktopClientRawService.php b/src/Services/BeeperDesktopClientRawService.php index e40a60d..ee0acfe 100644 --- a/src/Services/BeeperDesktopClientRawService.php +++ b/src/Services/BeeperDesktopClientRawService.php @@ -15,6 +15,8 @@ use BeeperDesktop\ServiceContracts\BeeperDesktopClientRawContract; /** + * Control the Beeper Desktop application. + * * @phpstan-import-type RequestOpts from \BeeperDesktop\RequestOptions */ final class BeeperDesktopClientRawService implements BeeperDesktopClientRawContract diff --git a/src/Services/BeeperDesktopClientService.php b/src/Services/BeeperDesktopClientService.php index 9f2272f..0d44a9a 100644 --- a/src/Services/BeeperDesktopClientService.php +++ b/src/Services/BeeperDesktopClientService.php @@ -13,6 +13,8 @@ use BeeperDesktop\ServiceContracts\BeeperDesktopClientContract; /** + * Control the Beeper Desktop application. + * * @phpstan-import-type RequestOpts from \BeeperDesktop\RequestOptions */ final class BeeperDesktopClientService implements BeeperDesktopClientContract diff --git a/src/Services/ChatsRawService.php b/src/Services/ChatsRawService.php index 1df2645..34751aa 100644 --- a/src/Services/ChatsRawService.php +++ b/src/Services/ChatsRawService.php @@ -4,9 +4,9 @@ namespace BeeperDesktop\Services; +use BeeperDesktop\Chats\Chat; use BeeperDesktop\Chats\ChatArchiveParams; use BeeperDesktop\Chats\ChatCreateParams; -use BeeperDesktop\Chats\ChatCreateParams\Chat; use BeeperDesktop\Chats\ChatListParams; use BeeperDesktop\Chats\ChatListParams\Direction; use BeeperDesktop\Chats\ChatListResponse; @@ -27,7 +27,7 @@ /** * Manage chats. * - * @phpstan-import-type ChatShape from \BeeperDesktop\Chats\ChatCreateParams\Chat + * @phpstan-import-type ParamsShape from \BeeperDesktop\Chats\ChatCreateParams\Params * @phpstan-import-type RequestOpts from \BeeperDesktop\RequestOptions */ final class ChatsRawService implements ChatsRawContract @@ -43,7 +43,7 @@ public function __construct(private Client $client) {} * * Create a single/group chat (mode='create') or start a direct chat from merged user data (mode='start'). * - * @param array{chat: Chat|ChatShape}|ChatCreateParams $params + * @param array{params?: ParamsShape}|ChatCreateParams $params * @param RequestOpts|null $requestOptions * * @return BaseResponse @@ -63,7 +63,7 @@ public function create( return $this->client->request( method: 'post', path: 'v1/chats', - body: (object) $parsed['chat'], + body: (object) $parsed['params'], options: $options, convert: ChatNewResponse::class, ); @@ -78,7 +78,7 @@ public function create( * @param array{maxParticipantCount?: int|null}|ChatRetrieveParams $params * @param RequestOpts|null $requestOptions * - * @return BaseResponse<\BeeperDesktop\Chats\Chat> + * @return BaseResponse * * @throws APIException */ @@ -98,7 +98,7 @@ public function retrieve( path: ['v1/chats/%1$s', $chatID], query: $parsed, options: $options, - convert: \BeeperDesktop\Chats\Chat::class, + convert: Chat::class, ); } @@ -192,7 +192,7 @@ public function archive( * }|ChatSearchParams $params * @param RequestOpts|null $requestOptions * - * @return BaseResponse> + * @return BaseResponse> * * @throws APIException */ @@ -211,7 +211,7 @@ public function search( path: 'v1/chats/search', query: $parsed, options: $options, - convert: \BeeperDesktop\Chats\Chat::class, + convert: Chat::class, page: CursorSearch::class, ); } diff --git a/src/Services/ChatsService.php b/src/Services/ChatsService.php index 8ca0439..b103c76 100644 --- a/src/Services/ChatsService.php +++ b/src/Services/ChatsService.php @@ -4,7 +4,9 @@ namespace BeeperDesktop\Services; -use BeeperDesktop\Chats\ChatCreateParams\Chat; +use BeeperDesktop\Chats\Chat; +use BeeperDesktop\Chats\ChatCreateParams\Params\UnionMember0; +use BeeperDesktop\Chats\ChatCreateParams\Params\UnionMember1; use BeeperDesktop\Chats\ChatListParams\Direction; use BeeperDesktop\Chats\ChatListResponse; use BeeperDesktop\Chats\ChatNewResponse; @@ -24,7 +26,7 @@ /** * Manage chats. * - * @phpstan-import-type ChatShape from \BeeperDesktop\Chats\ChatCreateParams\Chat + * @phpstan-import-type ParamsShape from \BeeperDesktop\Chats\ChatCreateParams\Params * @phpstan-import-type RequestOpts from \BeeperDesktop\RequestOptions */ final class ChatsService implements ChatsContract @@ -59,19 +61,19 @@ public function __construct(private Client $client) * * Create a single/group chat (mode='create') or start a direct chat from merged user data (mode='start'). * - * @param Chat|ChatShape $chat + * @param ParamsShape $params * @param RequestOpts|null $requestOptions * * @throws APIException */ public function create( - Chat|array $chat, - RequestOptions|array|null $requestOptions = null + UnionMember0|array|UnionMember1|null $params = null, + RequestOptions|array|null $requestOptions = null, ): ChatNewResponse { - $params = Util::removeNulls(['chat' => $chat]); + $params1 = Util::removeNulls(['params' => $params]); // @phpstan-ignore-next-line argument.type - $response = $this->raw->create(params: $params, requestOptions: $requestOptions); + $response = $this->raw->create(params: $params1, requestOptions: $requestOptions); return $response->parse(); } @@ -91,7 +93,7 @@ public function retrieve( string $chatID, ?int $maxParticipantCount = -1, RequestOptions|array|null $requestOptions = null, - ): \BeeperDesktop\Chats\Chat { + ): Chat { $params = Util::removeNulls( ['maxParticipantCount' => $maxParticipantCount] ); @@ -179,7 +181,7 @@ public function archive( * @param bool|null $unreadOnly Set to true to only retrieve chats that have unread messages * @param RequestOpts|null $requestOptions * - * @return CursorSearch<\BeeperDesktop\Chats\Chat> + * @return CursorSearch * * @throws APIException */ diff --git a/src/Services/InfoRawService.php b/src/Services/InfoRawService.php index 853a5b8..9ed5030 100644 --- a/src/Services/InfoRawService.php +++ b/src/Services/InfoRawService.php @@ -12,6 +12,8 @@ use BeeperDesktop\ServiceContracts\InfoRawContract; /** + * Control the Beeper Desktop application. + * * @phpstan-import-type RequestOpts from \BeeperDesktop\RequestOptions */ final class InfoRawService implements InfoRawContract diff --git a/src/Services/InfoService.php b/src/Services/InfoService.php index c38342e..20ccaf2 100644 --- a/src/Services/InfoService.php +++ b/src/Services/InfoService.php @@ -11,6 +11,8 @@ use BeeperDesktop\ServiceContracts\InfoContract; /** + * Control the Beeper Desktop application. + * * @phpstan-import-type RequestOpts from \BeeperDesktop\RequestOptions */ final class InfoService implements InfoContract diff --git a/src/Services/MessagesRawService.php b/src/Services/MessagesRawService.php index 58ac32b..9d379ed 100644 --- a/src/Services/MessagesRawService.php +++ b/src/Services/MessagesRawService.php @@ -15,7 +15,6 @@ use BeeperDesktop\Messages\MessageSearchParams; use BeeperDesktop\Messages\MessageSearchParams\ChatType; use BeeperDesktop\Messages\MessageSearchParams\MediaType; -use BeeperDesktop\Messages\MessageSearchParams\Sender; use BeeperDesktop\Messages\MessageSendParams; use BeeperDesktop\Messages\MessageSendParams\Attachment; use BeeperDesktop\Messages\MessageSendResponse; @@ -127,7 +126,7 @@ public function list( * limit?: int, * mediaTypes?: list>, * query?: string, - * sender?: string|Sender|value-of, + * sender?: string, * }|MessageSearchParams $params * @param RequestOpts|null $requestOptions * diff --git a/src/Services/MessagesService.php b/src/Services/MessagesService.php index 70f0386..23d1d14 100644 --- a/src/Services/MessagesService.php +++ b/src/Services/MessagesService.php @@ -13,7 +13,6 @@ use BeeperDesktop\Messages\MessageListParams\Direction; use BeeperDesktop\Messages\MessageSearchParams\ChatType; use BeeperDesktop\Messages\MessageSearchParams\MediaType; -use BeeperDesktop\Messages\MessageSearchParams\Sender; use BeeperDesktop\Messages\MessageSendParams\Attachment; use BeeperDesktop\Messages\MessageSendResponse; use BeeperDesktop\Messages\MessageUpdateResponse; @@ -114,7 +113,7 @@ public function list( * @param int $limit maximum number of messages to return * @param list> $mediaTypes Filter messages by media types. Use ['any'] for any media type, or specify exact types like ['video', 'image']. Omit for no media filtering. * @param string $query Literal word search (non-semantic). Finds messages containing these EXACT words in any order. Use single words users actually type, not concepts or phrases. Example: use "dinner" not "dinner plans", use "sick" not "health issues". If omitted, returns results filtered only by other parameters. - * @param string|Sender|value-of $sender Filter by sender: 'me' (messages sent by the authenticated user), 'others' (messages sent by others), or a specific user ID string (user.id). + * @param string $sender Filter by sender: 'me' (messages sent by the authenticated user), 'others' (messages sent by others), or a specific user ID string (user.id). * @param RequestOpts|null $requestOptions * * @return CursorSearch @@ -134,7 +133,7 @@ public function search( int $limit = 20, ?array $mediaTypes = null, ?string $query = null, - Sender|string|null $sender = null, + ?string $sender = null, RequestOptions|array|null $requestOptions = null, ): CursorSearch { $params = Util::removeNulls( diff --git a/src/Version.php b/src/Version.php index fa38b72..10c344d 100644 --- a/src/Version.php +++ b/src/Version.php @@ -5,5 +5,5 @@ namespace BeeperDesktop; // x-release-please-start-version -const VERSION = '0.0.1'; +const VERSION = '0.1.0'; // x-release-please-end diff --git a/tests/Core/ModelTest.php b/tests/Core/ModelTest.php index 68e6e66..995da87 100644 --- a/tests/Core/ModelTest.php +++ b/tests/Core/ModelTest.php @@ -47,6 +47,30 @@ public function __construct( } } +enum TicketPriority: string +{ + case Low = 'low'; + case High = 'high'; +} + +class Ticket implements BaseModel +{ + /** @use SdkModel> */ + use SdkModel; + + #[Required(enum: TicketPriority::class)] + public TicketPriority $priority; + + /** @var list */ + #[Required(list: TicketPriority::class)] + public array $labels; + + public function __construct() + { + $this->initialize(); + } +} + /** * @internal * @@ -141,4 +165,42 @@ public function testSerializeModelWithExplicitNull(): void json_encode($model) ); } + + #[Test] + public function testScalarEnumCoercesToInstance(): void + { + $model = Ticket::fromArray(['priority' => 'low', 'labels' => []]); + $this->assertSame(TicketPriority::Low, $model->priority); + } + + #[Test] + public function testListOfEnumCoercesElementsToInstances(): void + { + $model = Ticket::fromArray(['priority' => 'low', 'labels' => ['low', 'high']]); + $this->assertCount(2, $model->labels); + $this->assertSame(TicketPriority::Low, $model->labels[0]); + $this->assertSame(TicketPriority::High, $model->labels[1]); + } + + #[Test] + public function testEnumInstancePassesThrough(): void + { + $model = Ticket::fromArray(['priority' => TicketPriority::High, 'labels' => []]); + $this->assertSame(TicketPriority::High, $model->priority); + } + + #[Test] + public function testInvalidEnumScalarFallsBackToData(): void + { + $model = Ticket::fromArray(['priority' => 'urgent', 'labels' => []]); + $this->assertSame('urgent', $model['priority']); + } + + #[Test] + public function testEnumWireFormatStableAcrossConstruction(): void + { + $fromScalar = Ticket::fromArray(['priority' => 'low', 'labels' => ['high']]); + $fromInstance = Ticket::fromArray(['priority' => TicketPriority::Low, 'labels' => [TicketPriority::High]]); + $this->assertSame(json_encode($fromScalar), json_encode($fromInstance)); + } } diff --git a/tests/Services/AssetsTest.php b/tests/Services/AssetsTest.php index 4ae3576..09c9a2e 100644 --- a/tests/Services/AssetsTest.php +++ b/tests/Services/AssetsTest.php @@ -6,6 +6,7 @@ use BeeperDesktop\Assets\AssetUploadBase64Response; use BeeperDesktop\Assets\AssetUploadResponse; use BeeperDesktop\Client; +use BeeperDesktop\Core\FileParam; use BeeperDesktop\Core\Util; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\Test; @@ -72,7 +73,9 @@ public function testServeWithOptionalParams(): void #[Test] public function testUpload(): void { - $result = $this->client->assets->upload(file: 'file'); + $result = $this->client->assets->upload( + file: FileParam::fromString('Example data', filename: uniqid('file-upload-', true)), + ); // @phpstan-ignore-next-line method.alreadyNarrowedType $this->assertInstanceOf(AssetUploadResponse::class, $result); @@ -82,9 +85,9 @@ public function testUpload(): void public function testUploadWithOptionalParams(): void { $result = $this->client->assets->upload( - file: 'file', + file: FileParam::fromString('Example data', filename: uniqid('file-upload-', true)), fileName: 'fileName', - mimeType: 'mimeType' + mimeType: 'mimeType', ); // @phpstan-ignore-next-line method.alreadyNarrowedType diff --git a/tests/Services/ChatsTest.php b/tests/Services/ChatsTest.php index 71d8111..5ead909 100644 --- a/tests/Services/ChatsTest.php +++ b/tests/Services/ChatsTest.php @@ -34,33 +34,7 @@ protected function setUp(): void #[Test] public function testCreate(): void { - $result = $this->client->chats->create(chat: ['accountID' => 'accountID']); - - // @phpstan-ignore-next-line method.alreadyNarrowedType - $this->assertInstanceOf(ChatNewResponse::class, $result); - } - - #[Test] - public function testCreateWithOptionalParams(): void - { - $result = $this->client->chats->create( - chat: [ - 'accountID' => 'accountID', - 'allowInvite' => true, - 'messageText' => 'messageText', - 'mode' => 'create', - 'participantIDs' => ['string'], - 'title' => 'title', - 'type' => 'single', - 'user' => [ - 'id' => 'id', - 'email' => 'email', - 'fullName' => 'fullName', - 'phoneNumber' => 'phoneNumber', - 'username' => 'username', - ], - ], - ); + $result = $this->client->chats->create(); // @phpstan-ignore-next-line method.alreadyNarrowedType $this->assertInstanceOf(ChatNewResponse::class, $result);