From b50e565378a76aae6600dd34000a5f91ec9d7b02 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:56:21 +0000 Subject: [PATCH 01/24] feat(php-sdk): support optional username/password in basic auth when configured in IR Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/root-client/RootClientGenerator.ts | 21 +- generators/php/sdk/versions.yml | 12 + ...e_errors_UnauthorizedRequestErrorBody.json | 13 + .../basic-auth-optional/.fern/metadata.json | 7 + .../.github/workflows/ci.yml | 52 + seed/php-sdk/basic-auth-optional/.gitignore | 5 + seed/php-sdk/basic-auth-optional/README.md | 145 +++ .../php-sdk/basic-auth-optional/composer.json | 46 + seed/php-sdk/basic-auth-optional/phpstan.neon | 6 + seed/php-sdk/basic-auth-optional/phpunit.xml | 7 + seed/php-sdk/basic-auth-optional/reference.md | 99 ++ seed/php-sdk/basic-auth-optional/snippet.json | 0 .../src/BasicAuth/BasicAuthClient.php | 146 +++ .../src/Core/Client/BaseApiRequest.php | 22 + .../src/Core/Client/HttpClientBuilder.php | 56 + .../src/Core/Client/HttpMethod.php | 12 + .../src/Core/Client/MockHttpClient.php | 75 ++ .../src/Core/Client/RawClient.php | 310 +++++ .../src/Core/Client/RetryDecoratingClient.php | 241 ++++ .../src/Core/Json/JsonApiRequest.php | 28 + .../src/Core/Json/JsonDecoder.php | 161 +++ .../src/Core/Json/JsonDeserializer.php | 218 ++++ .../src/Core/Json/JsonEncoder.php | 20 + .../src/Core/Json/JsonProperty.php | 13 + .../src/Core/Json/JsonSerializableType.php | 225 ++++ .../src/Core/Json/JsonSerializer.php | 205 ++++ .../src/Core/Json/Utils.php | 62 + .../Core/Multipart/MultipartApiRequest.php | 28 + .../src/Core/Multipart/MultipartFormData.php | 58 + .../Core/Multipart/MultipartFormDataPart.php | 62 + .../src/Core/Types/ArrayType.php | 16 + .../src/Core/Types/Constant.php | 12 + .../src/Core/Types/Date.php | 16 + .../src/Core/Types/Union.php | 62 + .../Types/UnauthorizedRequestErrorBody.php | 34 + .../src/Exceptions/SeedApiException.php | 53 + .../src/Exceptions/SeedException.php | 12 + .../basic-auth-optional/src/SeedClient.php | 69 ++ .../basic-auth-optional/src/Utils/File.php | 129 ++ .../src/dynamic-snippets/example0/snippet.php | 14 + .../src/dynamic-snippets/example1/snippet.php | 14 + .../src/dynamic-snippets/example2/snippet.php | 14 + .../src/dynamic-snippets/example3/snippet.php | 18 + .../src/dynamic-snippets/example4/snippet.php | 18 + .../src/dynamic-snippets/example5/snippet.php | 18 + .../src/dynamic-snippets/example6/snippet.php | 18 + .../tests/Core/Client/RawClientTest.php | 1074 +++++++++++++++++ .../Core/Json/AdditionalPropertiesTest.php | 76 ++ .../tests/Core/Json/DateArrayTest.php | 54 + .../tests/Core/Json/EmptyArrayTest.php | 71 ++ .../tests/Core/Json/EnumTest.php | 77 ++ .../tests/Core/Json/ExhaustiveTest.php | 197 +++ .../tests/Core/Json/InvalidTest.php | 42 + .../tests/Core/Json/NestedUnionArrayTest.php | 89 ++ .../tests/Core/Json/NullPropertyTest.php | 53 + .../tests/Core/Json/NullableArrayTest.php | 49 + .../tests/Core/Json/ScalarTest.php | 116 ++ .../tests/Core/Json/TraitTest.php | 60 + .../tests/Core/Json/UnionArrayTest.php | 57 + .../tests/Core/Json/UnionPropertyTest.php | 111 ++ .../basic-auth-optional/definition/api.yml | 12 + .../definition/basic-auth.yml | 39 + .../basic-auth-optional/definition/errors.yml | 11 + .../apis/basic-auth-optional/generators.yml | 22 + 64 files changed, 5076 insertions(+), 6 deletions(-) create mode 100644 packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/basic-auth-optional/type_errors_UnauthorizedRequestErrorBody.json create mode 100644 seed/php-sdk/basic-auth-optional/.fern/metadata.json create mode 100644 seed/php-sdk/basic-auth-optional/.github/workflows/ci.yml create mode 100644 seed/php-sdk/basic-auth-optional/.gitignore create mode 100644 seed/php-sdk/basic-auth-optional/README.md create mode 100644 seed/php-sdk/basic-auth-optional/composer.json create mode 100644 seed/php-sdk/basic-auth-optional/phpstan.neon create mode 100644 seed/php-sdk/basic-auth-optional/phpunit.xml create mode 100644 seed/php-sdk/basic-auth-optional/reference.md create mode 100644 seed/php-sdk/basic-auth-optional/snippet.json create mode 100644 seed/php-sdk/basic-auth-optional/src/BasicAuth/BasicAuthClient.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Client/BaseApiRequest.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Client/HttpClientBuilder.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Client/HttpMethod.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Client/MockHttpClient.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Client/RawClient.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Client/RetryDecoratingClient.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Json/JsonApiRequest.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDecoder.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDeserializer.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Json/JsonEncoder.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Json/JsonProperty.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Json/JsonSerializableType.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Json/JsonSerializer.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Json/Utils.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartApiRequest.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormData.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormDataPart.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Types/ArrayType.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Types/Constant.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Types/Date.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Core/Types/Union.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Errors/Types/UnauthorizedRequestErrorBody.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Exceptions/SeedApiException.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Exceptions/SeedException.php create mode 100644 seed/php-sdk/basic-auth-optional/src/SeedClient.php create mode 100644 seed/php-sdk/basic-auth-optional/src/Utils/File.php create mode 100644 seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example0/snippet.php create mode 100644 seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example1/snippet.php create mode 100644 seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example2/snippet.php create mode 100644 seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example3/snippet.php create mode 100644 seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example4/snippet.php create mode 100644 seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example5/snippet.php create mode 100644 seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example6/snippet.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Client/RawClientTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/AdditionalPropertiesTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/DateArrayTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/EmptyArrayTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/EnumTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/ExhaustiveTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/InvalidTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/NestedUnionArrayTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/NullPropertyTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/NullableArrayTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/ScalarTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/TraitTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionArrayTest.php create mode 100644 seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionPropertyTest.php create mode 100644 test-definitions/fern/apis/basic-auth-optional/definition/api.yml create mode 100644 test-definitions/fern/apis/basic-auth-optional/definition/basic-auth.yml create mode 100644 test-definitions/fern/apis/basic-auth-optional/definition/errors.yml create mode 100644 test-definitions/fern/apis/basic-auth-optional/generators.yml diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index 5b39fa323a2f..6ea201ae4301 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -356,16 +356,25 @@ export class RootClientGenerator extends FileGenerator; + const eitherOmitted = scheme.usernameOmit === true || scheme.passwordOmit === true; if (isAuthOptional || basicAuthSchemes.length > 1) { const controlFlowKeyword = i === 0 ? "if" : "else if"; - writer.controlFlow( - controlFlowKeyword, - php.codeblock(`$${usernameName} !== null && $${passwordName} !== null`) + const condition = eitherOmitted + ? `$${usernameName} !== null || $${passwordName} !== null` + : `$${usernameName} !== null && $${passwordName} !== null`; + writer.controlFlow(controlFlowKeyword, php.codeblock(condition)); + } + if (eitherOmitted) { + writer.writeLine( + `$defaultHeaders['Authorization'] = "Basic " . base64_encode(($${usernameName} ?? "") . ":" . ($${passwordName} ?? ""));` + ); + } else { + writer.writeLine( + `$defaultHeaders['Authorization'] = "Basic " . base64_encode($${usernameName} . ":" . $${passwordName});` ); } - writer.writeLine( - `$defaultHeaders['Authorization'] = "Basic " . base64_encode($${usernameName} . ":" . $${passwordName});` - ); if (isAuthOptional || basicAuthSchemes.length > 1) { writer.endControlFlow(); } diff --git a/generators/php/sdk/versions.yml b/generators/php/sdk/versions.yml index c5db51bb94ff..613468c103d3 100644 --- a/generators/php/sdk/versions.yml +++ b/generators/php/sdk/versions.yml @@ -1,4 +1,16 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 2.2.7 + changelogEntry: + - summary: | + Support optional username and password in basic auth. The SDK now accepts + username-only, password-only, or both credentials. Missing fields are treated + as empty strings (e.g., username-only encodes `username:`, password-only + encodes `:password`). When neither is provided, the Authorization header is + omitted entirely. + type: feat + createdAt: "2026-03-31" + irVersion: 62 + - version: 2.2.6 changelogEntry: - summary: | diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/basic-auth-optional/type_errors_UnauthorizedRequestErrorBody.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/basic-auth-optional/type_errors_UnauthorizedRequestErrorBody.json new file mode 100644 index 000000000000..f50ccac10d76 --- /dev/null +++ b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/basic-auth-optional/type_errors_UnauthorizedRequestErrorBody.json @@ -0,0 +1,13 @@ +{ + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": false, + "definitions": {} +} \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-optional/.fern/metadata.json b/seed/php-sdk/basic-auth-optional/.fern/metadata.json new file mode 100644 index 000000000000..37f759a1679d --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/.fern/metadata.json @@ -0,0 +1,7 @@ +{ + "cliVersion": "DUMMY", + "generatorName": "fernapi/fern-php-sdk", + "generatorVersion": "latest", + "originGitCommit": "DUMMY", + "sdkVersion": "0.0.1" +} \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-optional/.github/workflows/ci.yml b/seed/php-sdk/basic-auth-optional/.github/workflows/ci.yml new file mode 100644 index 000000000000..678eb6c9e141 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: ci + +on: [push] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + + - name: Install tools + run: | + composer install + + - name: Build + run: | + composer build + + - name: Analyze + run: | + composer analyze + + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + + - name: Install tools + run: | + composer install + + - name: Run Tests + run: | + composer test diff --git a/seed/php-sdk/basic-auth-optional/.gitignore b/seed/php-sdk/basic-auth-optional/.gitignore new file mode 100644 index 000000000000..31a1aeb14f35 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/.gitignore @@ -0,0 +1,5 @@ +.idea +.php-cs-fixer.cache +.phpunit.result.cache +composer.lock +vendor/ \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-optional/README.md b/seed/php-sdk/basic-auth-optional/README.md new file mode 100644 index 000000000000..269cd0bb42b1 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/README.md @@ -0,0 +1,145 @@ +# Seed PHP Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FPHP) +[![php shield](https://img.shields.io/badge/php-packagist-pink)](https://packagist.org/packages/seed/seed) + +The Seed PHP library provides convenient access to the Seed APIs from PHP. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Usage](#usage) +- [Exception Handling](#exception-handling) +- [Advanced](#advanced) + - [Custom Client](#custom-client) + - [Retries](#retries) + - [Timeouts](#timeouts) +- [Contributing](#contributing) + +## Requirements + +This SDK requires PHP ^8.1. + +## Installation + +```sh +composer require seed/seed +``` + +## Usage + +Instantiate and use the client with the following: + +```php +', + password: '', +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); + +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), an exception will be thrown. + +```php +use Seed\Exceptions\SeedApiException; +use Seed\Exceptions\SeedException; + +try { + $response = $client->basicAuth->postWithBasicAuth(...); +} catch (SeedApiException $e) { + echo 'API Exception occurred: ' . $e->getMessage() . "\n"; + echo 'Status Code: ' . $e->getCode() . "\n"; + echo 'Response Body: ' . $e->getBody() . "\n"; + // Optionally, rethrow the exception or handle accordingly. +} +``` + +## Advanced + +### Custom Client + +This SDK is built to work with any HTTP client that implements the [PSR-18](https://www.php-fig.org/psr/psr-18/) `ClientInterface`. +By default, if no client is provided, the SDK will use `php-http/discovery` to find an installed HTTP client. +However, you can pass your own client that adheres to `ClientInterface`: + +```php +use Seed\SeedClient; + +// Pass any PSR-18 compatible HTTP client implementation. +// For example, using Guzzle: +$customClient = new \GuzzleHttp\Client([ + 'timeout' => 5.0, +]); + +$client = new SeedClient(options: [ + 'client' => $customClient +]); + +// Or using Symfony HttpClient: +// $customClient = (new \Symfony\Component\HttpClient\Psr18Client()) +// ->withOptions(['timeout' => 5.0]); +// +// $client = new SeedClient(options: [ +// 'client' => $customClient +// ]); +``` + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `maxRetries` request option to configure this behavior. + +```php +$response = $client->basicAuth->postWithBasicAuth( + ..., + options: [ + 'maxRetries' => 0 // Override maxRetries at the request level + ] +); +``` + +### Timeouts + +The SDK defaults to a 30 second timeout. Use the `timeout` option to configure this behavior. + +```php +$response = $client->basicAuth->postWithBasicAuth( + ..., + options: [ + 'timeout' => 3.0 // Override timeout at the request level + ] +); +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! diff --git a/seed/php-sdk/basic-auth-optional/composer.json b/seed/php-sdk/basic-auth-optional/composer.json new file mode 100644 index 000000000000..ad30960a8764 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/composer.json @@ -0,0 +1,46 @@ +{ + "name": "seed/seed", + "version": "0.0.1", + "description": "Seed PHP Library", + "keywords": [ + "seed", + "api", + "sdk" + ], + "license": [], + "require": { + "php": "^8.1", + "ext-json": "*", + "psr/http-client": "^1.0", + "psr/http-client-implementation": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "php-http/discovery": "^1.0", + "php-http/multipart-stream-builder": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "friendsofphp/php-cs-fixer": "3.5.0", + "phpstan/phpstan": "^1.12", + "guzzlehttp/guzzle": "^7.4" + }, + "autoload": { + "psr-4": { + "Seed\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Seed\\Tests\\": "tests/" + } + }, + "scripts": { + "build": [ + "@php -l src", + "@php -l tests" + ], + "test": "phpunit", + "analyze": "phpstan analyze src tests --memory-limit=1G" + } +} \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-optional/phpstan.neon b/seed/php-sdk/basic-auth-optional/phpstan.neon new file mode 100644 index 000000000000..780706b8f8a2 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: max + reportUnmatchedIgnoredErrors: false + paths: + - src + - tests \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-optional/phpunit.xml b/seed/php-sdk/basic-auth-optional/phpunit.xml new file mode 100644 index 000000000000..54630a51163c --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/phpunit.xml @@ -0,0 +1,7 @@ + + + + tests + + + \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-optional/reference.md b/seed/php-sdk/basic-auth-optional/reference.md new file mode 100644 index 000000000000..76bf05ae1117 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/reference.md @@ -0,0 +1,99 @@ +# Reference +## BasicAuth +
$client->basicAuth->getWithBasicAuth() -> ?bool +
+
+ +#### 📝 Description + +
+
+ +
+
+ +GET request with basic auth scheme +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->basicAuth->getWithBasicAuth(); +``` +
+
+
+
+ + +
+
+
+ +
$client->basicAuth->postWithBasicAuth($request) -> ?bool +
+
+ +#### 📝 Description + +
+
+ +
+
+ +POST request with basic auth scheme +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$request:** `mixed` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/php-sdk/basic-auth-optional/snippet.json b/seed/php-sdk/basic-auth-optional/snippet.json new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/seed/php-sdk/basic-auth-optional/src/BasicAuth/BasicAuthClient.php b/seed/php-sdk/basic-auth-optional/src/BasicAuth/BasicAuthClient.php new file mode 100644 index 000000000000..ae6eddc5d2e0 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/BasicAuth/BasicAuthClient.php @@ -0,0 +1,146 @@ +, + * } $options @phpstan-ignore-next-line Property is used in endpoint methods via HttpEndpointGenerator + */ + private array $options; + + /** + * @var RawClient $client + */ + private RawClient $client; + + /** + * @param RawClient $client + * @param ?array{ + * baseUrl?: string, + * client?: ClientInterface, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * } $options + */ + public function __construct( + RawClient $client, + ?array $options = null, + ) { + $this->client = $client; + $this->options = $options ?? []; + } + + /** + * GET request with basic auth scheme + * + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?bool + * @throws SeedException + * @throws SeedApiException + */ + public function getWithBasicAuth(?array $options = null): ?bool + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', + path: "basic-auth", + method: HttpMethod::GET, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return JsonDecoder::decodeBool($json); + } + } catch (JsonException $e) { + throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + throw new SeedApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } + + /** + * POST request with basic auth scheme + * + * @param mixed $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?bool + * @throws SeedException + * @throws SeedApiException + */ + public function postWithBasicAuth(mixed $request, ?array $options = null): ?bool + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', + path: "basic-auth", + method: HttpMethod::POST, + body: $request, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return JsonDecoder::decodeBool($json); + } + } catch (JsonException $e) { + throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + throw new SeedApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/BaseApiRequest.php b/seed/php-sdk/basic-auth-optional/src/Core/Client/BaseApiRequest.php new file mode 100644 index 000000000000..5e1283e2b6f6 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Client/BaseApiRequest.php @@ -0,0 +1,22 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + */ + public function __construct( + public readonly string $baseUrl, + public readonly string $path, + public readonly HttpMethod $method, + public readonly array $headers = [], + public readonly array $query = [], + ) { + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/HttpClientBuilder.php b/seed/php-sdk/basic-auth-optional/src/Core/Client/HttpClientBuilder.php new file mode 100644 index 000000000000..8ac806af0325 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Client/HttpClientBuilder.php @@ -0,0 +1,56 @@ + + */ + private array $responses = []; + + /** + * @var array + */ + private array $requests = []; + + /** + * @param ResponseInterface ...$responses + */ + public function append(ResponseInterface ...$responses): void + { + foreach ($responses as $response) { + $this->responses[] = $response; + } + } + + /** + * @param RequestInterface $request + * @return ResponseInterface + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + + if (empty($this->responses)) { + throw new RuntimeException('No more responses in the queue. Add responses using append().'); + } + + return array_shift($this->responses); + } + + /** + * @return ?RequestInterface + */ + public function getLastRequest(): ?RequestInterface + { + if (empty($this->requests)) { + return null; + } + return $this->requests[count($this->requests) - 1]; + } + + /** + * @return int + */ + public function getRequestCount(): int + { + return count($this->requests); + } + + /** + * Returns the number of remaining responses in the queue. + * + * @return int + */ + public function count(): int + { + return count($this->responses); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/RawClient.php b/seed/php-sdk/basic-auth-optional/src/Core/Client/RawClient.php new file mode 100644 index 000000000000..14716c7d678b --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Client/RawClient.php @@ -0,0 +1,310 @@ + $headers + */ + private array $headers; + + /** + * @var ?(callable(): array) $getAuthHeaders + */ + private $getAuthHeaders; + + /** + * @param ?array{ + * baseUrl?: string, + * client?: ClientInterface, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * getAuthHeaders?: callable(): array, + * } $options + */ + public function __construct( + public readonly ?array $options = null, + ) { + $this->client = HttpClientBuilder::build( + $this->options['client'] ?? null, + $this->options['maxRetries'] ?? 2, + ); + $this->requestFactory = HttpClientBuilder::requestFactory(); + $this->streamFactory = HttpClientBuilder::streamFactory(); + $this->headers = $this->options['headers'] ?? []; + $this->getAuthHeaders = $this->options['getAuthHeaders'] ?? null; + } + + /** + * @param BaseApiRequest $request + * @param ?array{ + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ResponseInterface + * @throws ClientExceptionInterface + */ + public function sendRequest( + BaseApiRequest $request, + ?array $options = null, + ): ResponseInterface { + $opts = $options ?? []; + $httpRequest = $this->buildRequest($request, $opts); + + $timeout = $opts['timeout'] ?? $this->options['timeout'] ?? null; + $maxRetries = $opts['maxRetries'] ?? null; + + return $this->client->send($httpRequest, $timeout, $maxRetries); + } + + /** + * @param BaseApiRequest $request + * @param array{ + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return RequestInterface + */ + private function buildRequest( + BaseApiRequest $request, + array $options + ): RequestInterface { + $url = $this->buildUrl($request, $options); + $headers = $this->encodeHeaders($request, $options); + + $httpRequest = $this->requestFactory->createRequest( + $request->method->name, + $url, + ); + + // Encode body and, for multipart, capture the Content-Type with boundary. + if ($request instanceof MultipartApiRequest && $request->body !== null) { + $builder = new MultipartStreamBuilder($this->streamFactory); + $request->body->addToBuilder($builder); + $httpRequest = $httpRequest->withBody($builder->build()); + $headers['Content-Type'] = "multipart/form-data; boundary={$builder->getBoundary()}"; + } else { + $body = $this->encodeRequestBody($request, $options); + if ($body !== null) { + $httpRequest = $httpRequest->withBody($body); + } + } + + foreach ($headers as $name => $value) { + $httpRequest = $httpRequest->withHeader($name, $value); + } + + return $httpRequest; + } + + /** + * @param BaseApiRequest $request + * @param array{ + * headers?: array, + * } $options + * @return array + */ + private function encodeHeaders( + BaseApiRequest $request, + array $options, + ): array { + $authHeaders = $this->getAuthHeaders !== null ? ($this->getAuthHeaders)() : []; + return match (get_class($request)) { + JsonApiRequest::class => array_merge( + [ + "Content-Type" => "application/json", + "Accept" => "*/*", + ], + $this->headers, + $authHeaders, + $request->headers, + $options['headers'] ?? [], + ), + MultipartApiRequest::class => array_merge( + $this->headers, + $authHeaders, + $request->headers, + $options['headers'] ?? [], + ), + default => throw new InvalidArgumentException('Unsupported request type: ' . get_class($request)), + }; + } + + /** + * @param BaseApiRequest $request + * @param array{ + * bodyProperties?: array, + * } $options + * @return ?StreamInterface + */ + private function encodeRequestBody( + BaseApiRequest $request, + array $options, + ): ?StreamInterface { + if ($request instanceof JsonApiRequest) { + return $request->body === null ? null : $this->streamFactory->createStream( + JsonEncoder::encode( + $this->buildJsonBody( + $request->body, + $options, + ), + ) + ); + } + + if ($request instanceof MultipartApiRequest) { + return null; + } + + throw new InvalidArgumentException('Unsupported request type: ' . get_class($request)); + } + + /** + * @param mixed $body + * @param array{ + * bodyProperties?: array, + * } $options + * @return mixed + */ + private function buildJsonBody( + mixed $body, + array $options, + ): mixed { + $overrideProperties = $options['bodyProperties'] ?? []; + if (is_array($body) && (empty($body) || self::isSequential($body))) { + return array_merge($body, $overrideProperties); + } + + if ($body instanceof JsonSerializable) { + $result = $body->jsonSerialize(); + } else { + $result = $body; + } + if (is_array($result)) { + $result = array_merge($result, $overrideProperties); + if (empty($result)) { + // force to be serialized as {} instead of [] + return (object)($result); + } + } + + return $result; + } + + /** + * @param BaseApiRequest $request + * @param array{ + * queryParameters?: array, + * } $options + * @return string + */ + private function buildUrl( + BaseApiRequest $request, + array $options, + ): string { + $baseUrl = $request->baseUrl; + $trimmedBaseUrl = rtrim($baseUrl, '/'); + $trimmedBasePath = ltrim($request->path, '/'); + $url = "{$trimmedBaseUrl}/{$trimmedBasePath}"; + $query = array_merge( + $request->query, + $options['queryParameters'] ?? [], + ); + if (!empty($query)) { + $url .= '?' . $this->encodeQuery($query); + } + return $url; + } + + /** + * @param array $query + * @return string + */ + private function encodeQuery(array $query): string + { + $parts = []; + foreach ($query as $key => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $parts[] = urlencode($key) . '=' . $this->encodeQueryValue($item); + } + } else { + $parts[] = urlencode($key) . '=' . $this->encodeQueryValue($value); + } + } + return implode('&', $parts); + } + + private function encodeQueryValue(mixed $value): string + { + if (is_string($value)) { + return urlencode($value); + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_scalar($value)) { + return urlencode((string)$value); + } + if (is_null($value)) { + return 'null'; + } + // Unreachable, but included for a best effort. + return urlencode(JsonEncoder::encode($value)); + } + + /** + * Check if an array is sequential, not associative. + * @param mixed[] $arr + * @return bool + */ + private static function isSequential(array $arr): bool + { + if (empty($arr)) { + return false; + } + $length = count($arr); + $keys = array_keys($arr); + for ($i = 0; $i < $length; $i++) { + if ($keys[$i] !== $i) { + return false; + } + } + return true; + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/RetryDecoratingClient.php b/seed/php-sdk/basic-auth-optional/src/Core/Client/RetryDecoratingClient.php new file mode 100644 index 000000000000..b16170cf2805 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Client/RetryDecoratingClient.php @@ -0,0 +1,241 @@ +client = $client; + $this->maxRetries = $maxRetries; + $this->baseDelay = $baseDelay; + $this->sleepFunction = $sleepFunction ?? 'usleep'; + } + + /** + * @param RequestInterface $request + * @return ResponseInterface + * @throws ClientExceptionInterface + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->send($request); + } + + /** + * Sends a request with optional per-request timeout and retry overrides. + * + * When a Guzzle or Symfony PSR-18 client is detected, the timeout is + * forwarded via the client's native API. For other PSR-18 clients the + * timeout value is silently ignored. + * + * @param RequestInterface $request + * @param ?float $timeout Timeout in seconds, or null to use the client default. + * @param ?int $maxRetries Maximum retry attempts, or null to use the client default. + * @return ResponseInterface + * @throws ClientExceptionInterface + */ + public function send( + RequestInterface $request, + ?float $timeout = null, + ?int $maxRetries = null, + ): ResponseInterface { + $maxRetries = $maxRetries ?? $this->maxRetries; + $retryAttempt = 0; + $lastResponse = null; + + while (true) { + try { + $lastResponse = $this->doSend($request, $timeout); + if (!$this->shouldRetry($retryAttempt, $maxRetries, $lastResponse)) { + return $lastResponse; + } + } catch (ClientExceptionInterface $e) { + if ($retryAttempt >= $maxRetries) { + throw $e; + } + } + + $retryAttempt++; + $delay = $this->getRetryDelay($retryAttempt, $lastResponse); + ($this->sleepFunction)($delay * 1000); // Convert milliseconds to microseconds + + // Rewind the request body so retries don't send an empty body. + $request->getBody()->rewind(); + } + } + + /** + * Dispatches the request to the underlying client, forwarding the timeout + * option to Guzzle or Symfony when available. + * + * @param RequestInterface $request + * @param ?float $timeout + * @return ResponseInterface + * @throws ClientExceptionInterface + */ + private function doSend(RequestInterface $request, ?float $timeout): ResponseInterface + { + static $warned = false; + + if ($timeout === null) { + return $this->client->sendRequest($request); + } + + if (class_exists('GuzzleHttp\ClientInterface') + && $this->client instanceof \GuzzleHttp\ClientInterface + ) { + return $this->client->send($request, ['timeout' => $timeout]); + } + if (class_exists('Symfony\Component\HttpClient\Psr18Client') + && $this->client instanceof \Symfony\Component\HttpClient\Psr18Client + ) { + /** @var ClientInterface $clientWithTimeout */ + $clientWithTimeout = $this->client->withOptions(['timeout' => $timeout]); + return $clientWithTimeout->sendRequest($request); + } + + if ($warned) { + return $this->client->sendRequest($request); + } + $warned = true; + trigger_error( + 'Timeout option is not supported for the current PSR-18 client (' + . get_class($this->client) + . '). Use Guzzle or Symfony HttpClient for timeout support.', + E_USER_WARNING, + ); + return $this->client->sendRequest($request); + } + + /** + * @param int $retryAttempt + * @param int $maxRetries + * @param ?ResponseInterface $response + * @return bool + */ + private function shouldRetry( + int $retryAttempt, + int $maxRetries, + ?ResponseInterface $response = null, + ): bool { + if ($retryAttempt >= $maxRetries) { + return false; + } + + if ($response !== null) { + return $response->getStatusCode() >= 500 || + in_array($response->getStatusCode(), self::RETRY_STATUS_CODES); + } + + return false; + } + + /** + * Calculate the retry delay based on response headers or exponential backoff. + * + * @param int $retryAttempt + * @param ?ResponseInterface $response + * @return int milliseconds + */ + private function getRetryDelay(int $retryAttempt, ?ResponseInterface $response): int + { + if ($response !== null) { + // Check Retry-After header + $retryAfter = $response->getHeaderLine('Retry-After'); + if ($retryAfter !== '') { + // Try parsing as integer (seconds) + if (is_numeric($retryAfter)) { + $retryAfterSeconds = (int)$retryAfter; + if ($retryAfterSeconds > 0) { + return min($retryAfterSeconds * 1000, self::MAX_RETRY_DELAY); + } + } + + // Try parsing as HTTP date + $retryAfterDate = strtotime($retryAfter); + if ($retryAfterDate !== false) { + $delay = ($retryAfterDate - time()) * 1000; + if ($delay > 0) { + return min(max($delay, 0), self::MAX_RETRY_DELAY); + } + } + } + + // Check X-RateLimit-Reset header + $rateLimitReset = $response->getHeaderLine('X-RateLimit-Reset'); + if ($rateLimitReset !== '' && is_numeric($rateLimitReset)) { + $resetTime = (int)$rateLimitReset; + $delay = ($resetTime * 1000) - (int)(microtime(true) * 1000); + if ($delay > 0) { + return $this->addPositiveJitter(min($delay, self::MAX_RETRY_DELAY)); + } + } + } + + // Fall back to exponential backoff with symmetric jitter + return $this->addSymmetricJitter( + min($this->exponentialDelay($retryAttempt), self::MAX_RETRY_DELAY) + ); + } + + /** + * Add positive jitter (0% to +20%) to the delay. + * + * @param int $delay + * @return int + */ + private function addPositiveJitter(int $delay): int + { + $jitterMultiplier = 1 + (mt_rand() / mt_getrandmax()) * self::JITTER_FACTOR; + return (int)($delay * $jitterMultiplier); + } + + /** + * Add symmetric jitter (-10% to +10%) to the delay. + * + * @param int $delay + * @return int + */ + private function addSymmetricJitter(int $delay): int + { + $jitterMultiplier = 1 + ((mt_rand() / mt_getrandmax()) - 0.5) * self::JITTER_FACTOR; + return (int)($delay * $jitterMultiplier); + } + + /** + * Default exponential backoff delay function. + * + * @return int milliseconds. + */ + private function exponentialDelay(int $retryAttempt): int + { + return 2 ** ($retryAttempt - 1) * $this->baseDelay; + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonApiRequest.php b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonApiRequest.php new file mode 100644 index 000000000000..8fdf493606e6 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonApiRequest.php @@ -0,0 +1,28 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + * @param mixed|null $body The JSON request body (optional) + */ + public function __construct( + string $baseUrl, + string $path, + HttpMethod $method, + array $headers = [], + array $query = [], + public readonly mixed $body = null + ) { + parent::__construct($baseUrl, $path, $method, $headers, $query); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDecoder.php b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDecoder.php new file mode 100644 index 000000000000..2ddff0273482 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDecoder.php @@ -0,0 +1,161 @@ + $type The type definition for deserialization. + * @return mixed[]|array The deserialized array. + * @throws JsonException If the decoded value is not an array. + */ + public static function decodeArray(string $json, array $type): array + { + $decoded = self::decode($json); + if (!is_array($decoded)) { + throw new JsonException("Unexpected non-array json value: " . $json); + } + return JsonDeserializer::deserializeArray($decoded, $type); + } + + /** + * Decodes a JSON string and deserializes it based on the provided union type definition. + * + * @param string $json The JSON string to decode. + * @param Union $union The union type definition for deserialization. + * @return mixed The deserialized value. + * @throws JsonException If the deserialization for all types in the union fails. + */ + public static function decodeUnion(string $json, Union $union): mixed + { + $decoded = self::decode($json); + return JsonDeserializer::deserializeUnion($decoded, $union); + } + /** + * Decodes a JSON string and returns a mixed. + * + * @param string $json The JSON string to decode. + * @return mixed The decoded mixed. + * @throws JsonException If the decoded value is not an mixed. + */ + public static function decodeMixed(string $json): mixed + { + return self::decode($json); + } + + /** + * Decodes a JSON string into a PHP value. + * + * @param string $json The JSON string to decode. + * @return mixed The decoded value. + * @throws JsonException If an error occurs during JSON decoding. + */ + public static function decode(string $json): mixed + { + return json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDeserializer.php b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDeserializer.php new file mode 100644 index 000000000000..ca73bb2b970e --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDeserializer.php @@ -0,0 +1,218 @@ + $data The array to be deserialized. + * @param array $type The type definition from the annotation. + * @return array The deserialized array. + * @throws JsonException If deserialization fails. + */ + public static function deserializeArray(array $data, array $type): array + { + return Utils::isMapType($type) + ? self::deserializeMap($data, $type) + : self::deserializeList($data, $type); + } + + /** + * Deserializes a value based on its type definition. + * + * @param mixed $data The data to deserialize. + * @param mixed $type The type definition. + * @return mixed The deserialized value. + * @throws JsonException If deserialization fails. + */ + private static function deserializeValue(mixed $data, mixed $type): mixed + { + if ($type instanceof Union) { + return self::deserializeUnion($data, $type); + } + + if (is_array($type)) { + return self::deserializeArray((array)$data, $type); + } + + if (gettype($type) !== "string") { + throw new JsonException("Unexpected non-string type."); + } + + return self::deserializeSingleValue($data, $type); + } + + /** + * Deserializes a value based on the possible types in a union type definition. + * + * @param mixed $data The data to deserialize. + * @param Union $type The union type definition. + * @return mixed The deserialized value. + * @throws JsonException If none of the union types can successfully deserialize the value. + */ + public static function deserializeUnion(mixed $data, Union $type): mixed + { + foreach ($type->types as $unionType) { + try { + return self::deserializeValue($data, $unionType); + } catch (\Throwable) { + // Catching Throwable instead of Exception to handle TypeError + // that occurs when assigning null to non-nullable typed properties + continue; + } + } + $readableType = Utils::getReadableType($data); + throw new JsonException( + "Cannot deserialize value of type $readableType with any of the union types: " . $type + ); + } + + /** + * Deserializes a single value based on its expected type. + * + * @param mixed $data The data to deserialize. + * @param string $type The expected type. + * @return mixed The deserialized value. + * @throws JsonException If deserialization fails. + */ + private static function deserializeSingleValue(mixed $data, string $type): mixed + { + if ($type === 'null' && $data === null) { + return null; + } + + if ($type === 'date' && is_string($data)) { + return self::deserializeDate($data); + } + + if ($type === 'datetime' && is_string($data)) { + return self::deserializeDateTime($data); + } + + if ($type === 'mixed') { + return $data; + } + + if (class_exists($type) && is_array($data)) { + /** @var array $data */ + return self::deserializeObject($data, $type); + } + + // Handle floats as a special case since gettype($data) returns "double" for float values in PHP, and because + // floats make come through from json_decoded as integers + if ($type === 'float' && (is_numeric($data))) { + return (float) $data; + } + + // Handle bools as a special case since gettype($data) returns "boolean" for bool values in PHP. + if ($type === 'bool' && is_bool($data)) { + return $data; + } + + if (gettype($data) === $type) { + return $data; + } + + throw new JsonException("Unable to deserialize value of type '" . gettype($data) . "' as '$type'."); + } + + /** + * Deserializes an array into an object of the given type. + * + * @param array $data The data to deserialize. + * @param string $type The class name of the object to deserialize into. + * + * @return object The deserialized object. + * + * @throws JsonException If the type does not implement JsonSerializableType. + */ + public static function deserializeObject(array $data, string $type): object + { + if (!is_subclass_of($type, JsonSerializableType::class)) { + throw new JsonException("$type is not a subclass of JsonSerializableType."); + } + return $type::jsonDeserialize($data); + } + + /** + * Deserializes a map (associative array) with defined key and value types. + * + * @param array $data The associative array to deserialize. + * @param array $type The type definition for the map. + * @return array The deserialized map. + * @throws JsonException If deserialization fails. + */ + private static function deserializeMap(array $data, array $type): array + { + $keyType = array_key_first($type); + if ($keyType === null) { + throw new JsonException("Unexpected no key in ArrayType."); + } + $keyType = (string) $keyType; + $valueType = $type[$keyType]; + /** @var array $result */ + $result = []; + + foreach ($data as $key => $item) { + $key = (string) Utils::castKey($key, $keyType); + $result[$key] = self::deserializeValue($item, $valueType); + } + + return $result; + } + + /** + * Deserializes a list (indexed array) with a defined value type. + * + * @param array $data The list to deserialize. + * @param array $type The type definition for the list. + * @return array The deserialized list. + * @throws JsonException If deserialization fails. + */ + private static function deserializeList(array $data, array $type): array + { + $valueType = $type[0]; + /** @var array */ + return array_map(fn ($item) => self::deserializeValue($item, $valueType), $data); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonEncoder.php b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonEncoder.php new file mode 100644 index 000000000000..0dbf3fcc9948 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonEncoder.php @@ -0,0 +1,20 @@ + Extra properties from JSON that don't map to class properties */ + private array $__additionalProperties = []; + + /** @var array Properties that have been explicitly set via setter methods */ + private array $__explicitlySetProperties = []; + + /** + * Serializes the object to a JSON string. + * + * @return string JSON-encoded string representation of the object. + * @throws Exception If encoding fails. + */ + public function toJson(): string + { + $serializedObject = $this->jsonSerialize(); + $encoded = JsonEncoder::encode($serializedObject); + if (!$encoded) { + throw new Exception("Could not encode type"); + } + return $encoded; + } + + /** + * Serializes the object to an array. + * + * @return mixed[] Array representation of the object. + * @throws JsonException If serialization fails. + */ + public function jsonSerialize(): array + { + $result = []; + $reflectionClass = new \ReflectionClass($this); + foreach ($reflectionClass->getProperties() as $property) { + $jsonKey = self::getJsonKey($property); + if ($jsonKey === null) { + continue; + } + $value = $property->getValue($this); + + // Handle DateTime properties + $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; + if ($dateTypeAttr && $value instanceof DateTime) { + $dateType = $dateTypeAttr->newInstance()->type; + $value = ($dateType === Date::TYPE_DATE) + ? JsonSerializer::serializeDate($value) + : JsonSerializer::serializeDateTime($value); + } + + // Handle Union annotations + $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; + if ($unionTypeAttr) { + $unionType = $unionTypeAttr->newInstance(); + $value = JsonSerializer::serializeUnion($value, $unionType); + } + + // Handle arrays with type annotations + $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; + if ($arrayTypeAttr && is_array($value)) { + $arrayType = $arrayTypeAttr->newInstance()->type; + $value = JsonSerializer::serializeArray($value, $arrayType); + } + + // Handle object + if (is_object($value)) { + $value = JsonSerializer::serializeObject($value); + } + + // Include the value if it's not null, OR if it was explicitly set (even to null) + if ($value !== null || array_key_exists($property->getName(), $this->__explicitlySetProperties)) { + $result[$jsonKey] = $value; + } + } + return $result; + } + + /** + * Deserializes a JSON string into an instance of the calling class. + * + * @param string $json JSON string to deserialize. + * @return static Deserialized object. + * @throws JsonException If decoding fails or the result is not an array. + * @throws Exception If deserialization fails. + */ + public static function fromJson(string $json): static + { + $decodedJson = JsonDecoder::decode($json); + if (!is_array($decodedJson)) { + throw new JsonException("Unexpected non-array decoded type: " . gettype($decodedJson)); + } + /** @var array $decodedJson */ + return self::jsonDeserialize($decodedJson); + } + + /** + * Deserializes an array into an instance of the calling class. + * + * @param array $data Array data to deserialize. + * @return static Deserialized object. + * @throws JsonException If deserialization fails. + */ + public static function jsonDeserialize(array $data): static + { + $reflectionClass = new \ReflectionClass(static::class); + $constructor = $reflectionClass->getConstructor(); + if ($constructor === null) { + throw new JsonException("No constructor found."); + } + + $args = []; + $properties = []; + $additionalProperties = []; + foreach ($reflectionClass->getProperties() as $property) { + $jsonKey = self::getJsonKey($property) ?? $property->getName(); + $properties[$jsonKey] = $property; + } + + foreach ($data as $jsonKey => $value) { + if (!isset($properties[$jsonKey])) { + // This JSON key doesn't map to any class property - add it to additionalProperties + $additionalProperties[$jsonKey] = $value; + continue; + } + + $property = $properties[$jsonKey]; + + // Handle Date annotation + $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; + if ($dateTypeAttr) { + $dateType = $dateTypeAttr->newInstance()->type; + if (!is_string($value)) { + throw new JsonException("Unexpected non-string type for date."); + } + $value = ($dateType === Date::TYPE_DATE) + ? JsonDeserializer::deserializeDate($value) + : JsonDeserializer::deserializeDateTime($value); + } + + // Handle Array annotation + $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; + if (is_array($value) && $arrayTypeAttr) { + $arrayType = $arrayTypeAttr->newInstance()->type; + $value = JsonDeserializer::deserializeArray($value, $arrayType); + } + + // Handle Union annotations + $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; + if ($unionTypeAttr) { + $unionType = $unionTypeAttr->newInstance(); + $value = JsonDeserializer::deserializeUnion($value, $unionType); + } + + // Handle object + $type = $property->getType(); + if (is_array($value) && $type instanceof ReflectionNamedType && !$type->isBuiltin()) { + /** @var array $arrayValue */ + $arrayValue = $value; + $value = JsonDeserializer::deserializeObject($arrayValue, $type->getName()); + } + + $args[$property->getName()] = $value; + } + + // Fill in any missing properties with defaults + foreach ($properties as $property) { + if (!isset($args[$property->getName()])) { + $args[$property->getName()] = $property->hasDefaultValue() ? $property->getDefaultValue() : null; + } + } + + // @phpstan-ignore-next-line + $result = new static($args); + $result->__additionalProperties = $additionalProperties; + return $result; + } + + /** + * Get properties from JSON that weren't mapped to class fields + * @return array + */ + public function getAdditionalProperties(): array + { + return $this->__additionalProperties; + } + + /** + * Mark a property as explicitly set. + * This ensures the property will be included in JSON serialization even if null. + * + * @param string $propertyName The name of the property to mark as explicitly set. + */ + protected function _setField(string $propertyName): void + { + $this->__explicitlySetProperties[$propertyName] = true; + } + + /** + * Retrieves the JSON key associated with a property. + * + * @param ReflectionProperty $property The reflection property. + * @return ?string The JSON key, or null if not available. + */ + private static function getJsonKey(ReflectionProperty $property): ?string + { + $jsonPropertyAttr = $property->getAttributes(JsonProperty::class)[0] ?? null; + return $jsonPropertyAttr?->newInstance()?->name; + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonSerializer.php b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonSerializer.php new file mode 100644 index 000000000000..216de5aa4554 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonSerializer.php @@ -0,0 +1,205 @@ +format(Constant::DateFormat); + } + + /** + * Serializes a DateTime object into a string using the date-time format. + * Normalizes UTC times to use 'Z' suffix instead of '+00:00'. + * + * @param DateTime $date The DateTime object to serialize. + * @return string The serialized date-time string. + */ + public static function serializeDateTime(DateTime $date): string + { + $formatted = $date->format(Constant::DateTimeFormat); + if (str_ends_with($formatted, '+00:00')) { + return substr($formatted, 0, -6) . 'Z'; + } + return $formatted; + } + + /** + * Serializes an array based on type annotations (either a list or map). + * + * @param array $data The array to be serialized. + * @param array $type The type definition from the annotation. + * @return array The serialized array. + * @throws JsonException If serialization fails. + */ + public static function serializeArray(array $data, array $type): array + { + return Utils::isMapType($type) + ? self::serializeMap($data, $type) + : self::serializeList($data, $type); + } + + /** + * Serializes a value based on its type definition. + * + * @param mixed $data The value to serialize. + * @param mixed $type The type definition. + * @return mixed The serialized value. + * @throws JsonException If serialization fails. + */ + private static function serializeValue(mixed $data, mixed $type): mixed + { + if ($type instanceof Union) { + return self::serializeUnion($data, $type); + } + + if (is_array($type)) { + return self::serializeArray((array)$data, $type); + } + + if (gettype($type) !== "string") { + throw new JsonException("Unexpected non-string type."); + } + + return self::serializeSingleValue($data, $type); + } + + /** + * Serializes a value for a union type definition. + * + * @param mixed $data The value to serialize. + * @param Union $unionType The union type definition. + * @return mixed The serialized value. + * @throws JsonException If serialization fails for all union types. + */ + public static function serializeUnion(mixed $data, Union $unionType): mixed + { + foreach ($unionType->types as $type) { + try { + return self::serializeValue($data, $type); + } catch (Exception) { + // Try the next type in the union + continue; + } + } + $readableType = Utils::getReadableType($data); + throw new JsonException( + "Cannot serialize value of type $readableType with any of the union types: " . $unionType + ); + } + + /** + * Serializes a single value based on its type. + * + * @param mixed $data The value to serialize. + * @param string $type The expected type. + * @return mixed The serialized value. + * @throws JsonException If serialization fails. + */ + private static function serializeSingleValue(mixed $data, string $type): mixed + { + if ($type === 'null' && $data === null) { + return null; + } + + if (($type === 'date' || $type === 'datetime') && $data instanceof DateTime) { + return $type === 'date' ? self::serializeDate($data) : self::serializeDateTime($data); + } + + if ($type === 'mixed') { + return $data; + } + + if (class_exists($type) && $data instanceof $type) { + return self::serializeObject($data); + } + + // Handle floats as a special case since gettype($data) returns "double" for float values in PHP. + if ($type === 'float' && is_float($data)) { + return $data; + } + + // Handle bools as a special case since gettype($data) returns "boolean" for bool values in PHP. + if ($type === 'bool' && is_bool($data)) { + return $data; + } + + if (gettype($data) === $type) { + return $data; + } + + throw new JsonException("Unable to serialize value of type '" . gettype($data) . "' as '$type'."); + } + + /** + * Serializes an object to a JSON-serializable format. + * + * @param object $data The object to serialize. + * @return mixed The serialized data. + * @throws JsonException If the object does not implement JsonSerializable. + */ + public static function serializeObject(object $data): mixed + { + if (!is_subclass_of($data, JsonSerializable::class)) { + $type = get_class($data); + throw new JsonException("Class $type must implement JsonSerializable."); + } + return $data->jsonSerialize(); + } + + /** + * Serializes a map (associative array) with defined key and value types. + * + * @param array $data The associative array to serialize. + * @param array $type The type definition for the map. + * @return array The serialized map. + * @throws JsonException If serialization fails. + */ + private static function serializeMap(array $data, array $type): array + { + $keyType = array_key_first($type); + if ($keyType === null) { + throw new JsonException("Unexpected no key in ArrayType."); + } + $keyType = (string) $keyType; + $valueType = $type[$keyType]; + /** @var array $result */ + $result = []; + + foreach ($data as $key => $item) { + $key = (string) Utils::castKey($key, $keyType); + $result[$key] = self::serializeValue($item, $valueType); + } + + return $result; + } + + /** + * Serializes a list (indexed array) where only the value type is defined. + * + * @param array $data The list to serialize. + * @param array $type The type definition for the list. + * @return array The serialized list. + * @throws JsonException If serialization fails. + */ + private static function serializeList(array $data, array $type): array + { + $valueType = $type[0]; + /** @var array */ + return array_map(fn ($item) => self::serializeValue($item, $valueType), $data); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/Utils.php b/seed/php-sdk/basic-auth-optional/src/Core/Json/Utils.php new file mode 100644 index 000000000000..4099b8253005 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Json/Utils.php @@ -0,0 +1,62 @@ + $type The type definition from the annotation. + * @return bool True if the type is a map, false if it's a list. + */ + public static function isMapType(array $type): bool + { + return count($type) === 1 && !array_is_list($type); + } + + /** + * Casts the key to the appropriate type based on the key type. + * + * @param mixed $key The key to be cast. + * @param string $keyType The type to cast the key to ('string', 'integer', 'float'). + * @return int|string The casted key. + * @throws JsonException + */ + public static function castKey(mixed $key, string $keyType): int|string + { + if (!is_scalar($key)) { + throw new JsonException("Key must be a scalar type."); + } + return match ($keyType) { + 'integer' => (int)$key, + // PHP arrays don't support float keys; truncate to int + 'float' => (int)$key, + 'string' => (string)$key, + default => is_int($key) ? $key : (string)$key, + }; + } + + /** + * Returns a human-readable representation of the input's type. + * + * @param mixed $input The input value to determine the type of. + * @return string A readable description of the input type. + */ + public static function getReadableType(mixed $input): string + { + if (is_object($input)) { + return get_class($input); + } elseif (is_array($input)) { + return 'array(' . count($input) . ' items)'; + } elseif (is_null($input)) { + return 'null'; + } else { + return gettype($input); + } + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartApiRequest.php b/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartApiRequest.php new file mode 100644 index 000000000000..7760366456c8 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartApiRequest.php @@ -0,0 +1,28 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + * @param ?MultipartFormData $body The multipart form data for the request (optional) + */ + public function __construct( + string $baseUrl, + string $path, + HttpMethod $method, + array $headers = [], + array $query = [], + public readonly ?MultipartFormData $body = null + ) { + parent::__construct($baseUrl, $path, $method, $headers, $query); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormData.php b/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormData.php new file mode 100644 index 000000000000..911a28b6ad64 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormData.php @@ -0,0 +1,58 @@ + + */ + private array $parts = []; + + /** + * Adds a new part to the multipart form data. + * + * @param string $name + * @param string|int|bool|float|StreamInterface $value + * @param ?string $contentType + */ + public function add( + string $name, + string|int|bool|float|StreamInterface $value, + ?string $contentType = null, + ): void { + $headers = $contentType !== null ? ['Content-Type' => $contentType] : null; + $this->addPart( + new MultipartFormDataPart( + name: $name, + value: $value, + headers: $headers, + ) + ); + } + + /** + * Adds a new part to the multipart form data. + * + * @param MultipartFormDataPart $part + */ + public function addPart(MultipartFormDataPart $part): void + { + $this->parts[] = $part; + } + + /** + * Adds all parts to a MultipartStreamBuilder. + * + * @param MultipartStreamBuilder $builder + */ + public function addToBuilder(MultipartStreamBuilder $builder): void + { + foreach ($this->parts as $part) { + $part->addToBuilder($builder); + } + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormDataPart.php b/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormDataPart.php new file mode 100644 index 000000000000..4db35e58ae37 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormDataPart.php @@ -0,0 +1,62 @@ + + */ + private ?array $headers; + + /** + * @param string $name + * @param string|bool|float|int|StreamInterface $value + * @param ?string $filename + * @param ?array $headers + */ + public function __construct( + string $name, + string|bool|float|int|StreamInterface $value, + ?string $filename = null, + ?array $headers = null + ) { + $this->name = $name; + $this->contents = $value instanceof StreamInterface ? $value : (string)$value; + $this->filename = $filename; + $this->headers = $headers; + } + + /** + * Adds this part to a MultipartStreamBuilder. + * + * @param MultipartStreamBuilder $builder + */ + public function addToBuilder(MultipartStreamBuilder $builder): void + { + $options = array_filter([ + 'filename' => $this->filename, + 'headers' => $this->headers, + ], fn ($value) => $value !== null); + + $builder->addResource($this->name, $this->contents, $options); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Types/ArrayType.php b/seed/php-sdk/basic-auth-optional/src/Core/Types/ArrayType.php new file mode 100644 index 000000000000..a26d29008ec3 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Types/ArrayType.php @@ -0,0 +1,16 @@ + 'valueType'] for maps, or ['valueType'] for lists + */ + public function __construct(public array $type) + { + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Types/Constant.php b/seed/php-sdk/basic-auth-optional/src/Core/Types/Constant.php new file mode 100644 index 000000000000..5ac4518cc6d6 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Core/Types/Constant.php @@ -0,0 +1,12 @@ +> The types allowed for this property, which can be strings, arrays, or nested Union types. + */ + public array $types; + + /** + * Constructor for the Union attribute. + * + * @param string|Union|array ...$types The list of types that the property can accept. + * This can include primitive types (e.g., 'string', 'int'), arrays, or other Union instances. + * + * Example: + * ```php + * #[Union('string', 'null', 'date', new Union('boolean', 'int'))] + * ``` + */ + public function __construct(string|Union|array ...$types) + { + $this->types = $types; + } + + /** + * Converts the Union type to a string representation. + * + * @return string A string representation of the union types. + */ + public function __toString(): string + { + return implode(' | ', array_map(function ($type) { + if (is_string($type)) { + return $type; + } elseif ($type instanceof Union) { + return (string) $type; // Recursively handle nested unions + } elseif (is_array($type)) { + return 'array'; // Handle arrays + } + }, $this->types)); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Errors/Types/UnauthorizedRequestErrorBody.php b/seed/php-sdk/basic-auth-optional/src/Errors/Types/UnauthorizedRequestErrorBody.php new file mode 100644 index 000000000000..131d5f01b080 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Errors/Types/UnauthorizedRequestErrorBody.php @@ -0,0 +1,34 @@ +message = $values['message']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Exceptions/SeedApiException.php b/seed/php-sdk/basic-auth-optional/src/Exceptions/SeedApiException.php new file mode 100644 index 000000000000..6d0bba7c39b3 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Exceptions/SeedApiException.php @@ -0,0 +1,53 @@ +body = $body; + parent::__construct($message, $statusCode, $previous); + } + + /** + * Returns the body of the response that triggered the exception. + * + * @return mixed + */ + public function getBody(): mixed + { + return $this->body; + } + + /** + * @return string + */ + public function __toString(): string + { + if (empty($this->body)) { + return $this->message . '; Status Code: ' . $this->getCode() . "\n"; + } + return $this->message . '; Status Code: ' . $this->getCode() . '; Body: ' . print_r($this->body, true) . "\n"; + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Exceptions/SeedException.php b/seed/php-sdk/basic-auth-optional/src/Exceptions/SeedException.php new file mode 100644 index 000000000000..457035276737 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Exceptions/SeedException.php @@ -0,0 +1,12 @@ +, + * } $options @phpstan-ignore-next-line Property is used in endpoint methods via HttpEndpointGenerator + */ + private array $options; + + /** + * @var RawClient $client + */ + private RawClient $client; + + /** + * @param string $username The username to use for authentication. + * @param string $password The username to use for authentication. + * @param ?array{ + * baseUrl?: string, + * client?: ClientInterface, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * } $options + */ + public function __construct( + string $username, + string $password, + ?array $options = null, + ) { + $defaultHeaders = [ + 'X-Fern-Language' => 'PHP', + 'X-Fern-SDK-Name' => 'Seed', + 'X-Fern-SDK-Version' => '0.0.1', + 'User-Agent' => 'seed/seed/0.0.1', + ]; + $defaultHeaders['Authorization'] = "Basic " . base64_encode($username . ":" . $password); + + $this->options = $options ?? []; + + $this->options['headers'] = array_merge( + $defaultHeaders, + $this->options['headers'] ?? [], + ); + + $this->client = new RawClient( + options: $this->options, + ); + + $this->basicAuth = new BasicAuthClient($this->client, $this->options); + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/Utils/File.php b/seed/php-sdk/basic-auth-optional/src/Utils/File.php new file mode 100644 index 000000000000..ee2af27b8909 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/Utils/File.php @@ -0,0 +1,129 @@ +filename = $filename; + $this->contentType = $contentType; + $this->stream = $stream; + } + + /** + * Creates a File instance from a filepath. + * + * @param string $filepath + * @param ?string $filename + * @param ?string $contentType + * @return File + * @throws Exception + */ + public static function createFromFilepath( + string $filepath, + ?string $filename = null, + ?string $contentType = null, + ): File { + $resource = @fopen($filepath, 'r'); + if (!$resource) { + throw new Exception("Unable to open file $filepath"); + } + $stream = Psr17FactoryDiscovery::findStreamFactory()->createStreamFromResource($resource); + if (!$stream->isReadable()) { + throw new Exception("File $filepath is not readable"); + } + return new self( + stream: $stream, + filename: $filename ?? basename($filepath), + contentType: $contentType, + ); + } + + /** + * Creates a File instance from a string. + * + * @param string $content + * @param ?string $filename + * @param ?string $contentType + * @return File + */ + public static function createFromString( + string $content, + ?string $filename, + ?string $contentType = null, + ): File { + return new self( + stream: Psr17FactoryDiscovery::findStreamFactory()->createStream($content), + filename: $filename, + contentType: $contentType, + ); + } + + /** + * Maps this File into a multipart form data part. + * + * @param string $name The name of the multipart form data part. + * @param ?string $contentType Overrides the Content-Type associated with the file, if any. + * @return MultipartFormDataPart + */ + public function toMultipartFormDataPart(string $name, ?string $contentType = null): MultipartFormDataPart + { + $contentType ??= $this->contentType; + $headers = $contentType !== null + ? ['Content-Type' => $contentType] + : null; + + return new MultipartFormDataPart( + name: $name, + value: $this->stream, + filename: $this->filename, + headers: $headers, + ); + } + + /** + * Closes the file stream. + */ + public function close(): void + { + $this->stream->close(); + } + + /** + * Destructor to ensure stream is closed. + */ + public function __destruct() + { + try { + $this->close(); + } catch (\Throwable) { + // Swallow errors during garbage collection to avoid fatal errors. + } + } +} diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example0/snippet.php b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example0/snippet.php new file mode 100644 index 000000000000..f17c29ac0c45 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example0/snippet.php @@ -0,0 +1,14 @@ +', + password: '', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->getWithBasicAuth(); diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example1/snippet.php b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example1/snippet.php new file mode 100644 index 000000000000..f17c29ac0c45 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example1/snippet.php @@ -0,0 +1,14 @@ +', + password: '', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->getWithBasicAuth(); diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example2/snippet.php b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example2/snippet.php new file mode 100644 index 000000000000..f17c29ac0c45 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example2/snippet.php @@ -0,0 +1,14 @@ +', + password: '', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->getWithBasicAuth(); diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example3/snippet.php b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example3/snippet.php new file mode 100644 index 000000000000..379913d20cec --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example3/snippet.php @@ -0,0 +1,18 @@ +', + password: '', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example4/snippet.php b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example4/snippet.php new file mode 100644 index 000000000000..379913d20cec --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example4/snippet.php @@ -0,0 +1,18 @@ +', + password: '', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example5/snippet.php b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example5/snippet.php new file mode 100644 index 000000000000..379913d20cec --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example5/snippet.php @@ -0,0 +1,18 @@ +', + password: '', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example6/snippet.php b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example6/snippet.php new file mode 100644 index 000000000000..379913d20cec --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example6/snippet.php @@ -0,0 +1,18 @@ +', + password: '', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Client/RawClientTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Client/RawClientTest.php new file mode 100644 index 000000000000..df36dc918894 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Client/RawClientTest.php @@ -0,0 +1,1074 @@ +name = $values['name']; + } + + /** + * @return string + */ + public function getName(): ?string + { + return $this->name; + } +} + +class RawClientTest extends TestCase +{ + private string $baseUrl = 'https://api.example.com'; + private MockHttpClient $mockClient; + private RawClient $rawClient; + + protected function setUp(): void + { + $this->mockClient = new MockHttpClient(); + $this->rawClient = new RawClient(['client' => $this->mockClient, 'maxRetries' => 0]); + } + + /** + * @throws ClientExceptionInterface + */ + public function testHeaders(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ['X-Custom-Header' => 'TestValue'] + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('TestValue', $lastRequest->getHeaderLine('X-Custom-Header')); + } + + /** + * @throws ClientExceptionInterface + */ + public function testQueryParameters(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + [], + ['param1' => 'value1', 'param2' => ['a', 'b'], 'param3' => 'true'] + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals( + 'https://api.example.com/test?param1=value1¶m2=a¶m2=b¶m3=true', + (string)$lastRequest->getUri() + ); + } + + /** + * @throws ClientExceptionInterface + */ + public function testJsonBody(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = ['key' => 'value']; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + [], + $body + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals(JsonEncoder::encode($body), (string)$lastRequest->getBody()); + } + + public function testAdditionalHeaders(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = new JsonRequest([ + 'name' => 'john.doe' + ]); + $headers = [ + 'X-API-Version' => '1.0.0', + ]; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + $headers, + [], + $body + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'headers' => [ + 'X-Tenancy' => 'test' + ] + ] + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('1.0.0', $lastRequest->getHeaderLine('X-API-Version')); + $this->assertEquals('test', $lastRequest->getHeaderLine('X-Tenancy')); + } + + public function testOverrideAdditionalHeaders(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = new JsonRequest([ + 'name' => 'john.doe' + ]); + $headers = [ + 'X-API-Version' => '1.0.0', + ]; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + $headers, + [], + $body + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'headers' => [ + 'X-API-Version' => '2.0.0' + ] + ] + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('2.0.0', $lastRequest->getHeaderLine('X-API-Version')); + } + + public function testAdditionalBodyProperties(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = new JsonRequest([ + 'name' => 'john.doe' + ]); + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + [], + $body + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'bodyProperties' => [ + 'age' => 42 + ] + ] + ); + + $expectedJson = [ + 'name' => 'john.doe', + 'age' => 42 + ]; + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals(JsonEncoder::encode($expectedJson), (string)$lastRequest->getBody()); + } + + public function testOverrideAdditionalBodyProperties(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = [ + 'name' => 'john.doe' + ]; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + [], + $body + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'bodyProperties' => [ + 'name' => 'jane.doe' + ] + ] + ); + + $expectedJson = [ + 'name' => 'jane.doe', + ]; + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals(JsonEncoder::encode($expectedJson), (string)$lastRequest->getBody()); + } + + public function testAdditionalQueryParameters(): void + { + $this->mockClient->append(self::createResponse(200)); + + $query = ['key' => 'value']; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + $query, + [] + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'queryParameters' => [ + 'extra' => 42 + ] + ] + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('key=value&extra=42', $lastRequest->getUri()->getQuery()); + } + + public function testOverrideQueryParameters(): void + { + $this->mockClient->append(self::createResponse(200)); + + $query = ['key' => 'invalid']; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + $query, + [] + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'queryParameters' => [ + 'key' => 'value' + ] + ] + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('key=value', $lastRequest->getUri()->getQuery()); + } + + public function testDefaultRetries(): void + { + $this->mockClient->append(self::createResponse(500)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET + ); + + $response = $this->rawClient->sendRequest($request); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals(0, $this->mockClient->count()); + } + + /** + * @throws ClientExceptionInterface + */ + public function testExplicitRetriesSuccess(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(500), self::createResponse(500), self::createResponse(200)); + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->sendRequest($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(0, $mockClient->count()); + } + + public function testExplicitRetriesFailure(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(500), self::createResponse(500), self::createResponse(500)); + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->sendRequest($request); + + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals(0, $mockClient->count()); + } + + /** + * @throws ClientExceptionInterface + */ + public function testShouldRetryOnStatusCodes(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(408), + self::createResponse(429), + self::createResponse(500), + self::createResponse(501), + self::createResponse(502), + self::createResponse(503), + self::createResponse(504), + self::createResponse(505), + self::createResponse(599), + self::createResponse(200), + ); + $countOfErrorRequests = $mockClient->count() - 1; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: $countOfErrorRequests, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->sendRequest($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(0, $mockClient->count()); + } + + public function testShouldFailOn400Response(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(400), self::createResponse(200)); + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->sendRequest($request); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals(1, $mockClient->count()); + } + + public function testRetryAfterSecondsHeaderControlsDelay(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503, ['Retry-After' => '10']), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); // Convert microseconds to milliseconds + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(10000, $capturedDelays[0]); + $this->assertLessThanOrEqual(12000, $capturedDelays[0]); + } + + public function testRetryAfterHttpDateHeaderIsHandled(): void + { + $retryAfterDate = gmdate('D, d M Y H:i:s \G\M\T', time() + 5); + + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503, ['Retry-After' => $retryAfterDate]), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThan(0, $capturedDelays[0]); + $this->assertLessThanOrEqual(60000, $capturedDelays[0]); + } + + public function testRateLimitResetHeaderControlsDelay(): void + { + $resetTime = (int) floor(microtime(true)) + 5; + + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(429, ['X-RateLimit-Reset' => (string) $resetTime]), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThan(0, $capturedDelays[0]); + $this->assertLessThanOrEqual(60000, $capturedDelays[0]); + } + + public function testRateLimitResetHeaderRespectsMaxDelayAndPositiveJitter(): void + { + $resetTime = (int) floor(microtime(true)) + 1000; + + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(429, ['X-RateLimit-Reset' => (string) $resetTime]), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 1, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(60000, $capturedDelays[0]); + $this->assertLessThanOrEqual(72000, $capturedDelays[0]); + } + + public function testExponentialBackoffWithSymmetricJitterWhenNoHeaders(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 1, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(900, $capturedDelays[0]); + $this->assertLessThanOrEqual(1100, $capturedDelays[0]); + } + + public function testRetryAfterHeaderTakesPrecedenceOverRateLimitReset(): void + { + $resetTime = (int) floor(microtime(true)) + 30; + + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503, [ + 'Retry-After' => '5', + 'X-RateLimit-Reset' => (string) $resetTime, + ]), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(5000, $capturedDelays[0]); + $this->assertLessThanOrEqual(6000, $capturedDelays[0]); + } + + public function testMaxDelayCapIsApplied(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503, ['Retry-After' => '120']), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(60000, $capturedDelays[0]); + $this->assertLessThanOrEqual(72000, $capturedDelays[0]); + } + + public function testMultipartContentTypeIncludesBoundary(): void + { + $this->mockClient->append(self::createResponse(200)); + + $formData = new MultipartFormData(); + $formData->add('field', 'value'); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + [], + [], + $formData, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $contentType = $lastRequest->getHeaderLine('Content-Type'); + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + + $boundary = substr($contentType, strlen('multipart/form-data; boundary=')); + $body = (string) $lastRequest->getBody(); + $this->assertStringContainsString("--{$boundary}\r\n", $body); + $this->assertStringContainsString("Content-Disposition: form-data; name=\"field\"\r\n", $body); + $this->assertStringContainsString("value", $body); + $this->assertStringContainsString("--{$boundary}--\r\n", $body); + } + + public function testMultipartWithFilename(): void + { + $this->mockClient->append(self::createResponse(200)); + + $formData = new MultipartFormData(); + $formData->addPart(new MultipartFormDataPart( + name: 'document', + value: 'file-contents', + filename: 'report.pdf', + headers: ['Content-Type' => 'application/pdf'], + )); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + [], + [], + $formData, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $body = (string) $lastRequest->getBody(); + $this->assertStringContainsString( + 'Content-Disposition: form-data; name="document"; filename="report.pdf"', + $body, + ); + $this->assertStringContainsString('Content-Type: application/pdf', $body); + $this->assertStringContainsString('file-contents', $body); + } + + public function testMultipartWithMultipleParts(): void + { + $this->mockClient->append(self::createResponse(200)); + + $formData = new MultipartFormData(); + $formData->add('name', 'John'); + $formData->add('age', 30); + $formData->addPart(new MultipartFormDataPart( + name: 'avatar', + value: 'image-data', + filename: 'avatar.png', + )); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/profile', + HttpMethod::POST, + [], + [], + $formData, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $body = (string) $lastRequest->getBody(); + $this->assertStringContainsString('name="name"', $body); + $this->assertStringContainsString('John', $body); + $this->assertStringContainsString('name="age"', $body); + $this->assertStringContainsString('30', $body); + $this->assertStringContainsString('name="avatar"; filename="avatar.png"', $body); + $this->assertStringContainsString('image-data', $body); + } + + public function testMultipartDoesNotIncludeJsonContentType(): void + { + $this->mockClient->append(self::createResponse(200)); + + $formData = new MultipartFormData(); + $formData->add('field', 'value'); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + [], + [], + $formData, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $contentType = $lastRequest->getHeaderLine('Content-Type'); + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + $this->assertStringNotContainsString('application/json', $contentType); + } + + public function testMultipartNullBodySendsNoBody(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $this->assertEquals('', (string) $lastRequest->getBody()); + $this->assertStringNotContainsString('multipart/form-data', $lastRequest->getHeaderLine('Content-Type')); + } + + public function testJsonNullBodySendsNoBody(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $this->assertEquals('', (string) $lastRequest->getBody()); + } + + public function testEmptyJsonBodySerializesAsObject(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + [], + ['key' => 'value'], + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'bodyProperties' => [ + 'key' => 'value', + ], + ], + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + // When bodyProperties override all keys, the merged result should still + // serialize as a JSON object {}, not an array []. + $decoded = json_decode((string) $lastRequest->getBody(), true); + $this->assertIsArray($decoded); + $this->assertEquals('value', $decoded['key']); + } + + public function testAuthHeadersAreIncluded(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(200)); + + $rawClient = new RawClient([ + 'client' => $mockClient, + 'maxRetries' => 0, + 'getAuthHeaders' => fn () => ['Authorization' => 'Bearer test-token'], + ]); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ); + + $rawClient->sendRequest($request); + + $lastRequest = $mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $this->assertEquals('Bearer test-token', $lastRequest->getHeaderLine('Authorization')); + } + + public function testAuthHeadersAreIncludedInMultipart(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(200)); + + $rawClient = new RawClient([ + 'client' => $mockClient, + 'maxRetries' => 0, + 'getAuthHeaders' => fn () => ['Authorization' => 'Bearer test-token'], + ]); + + $formData = new MultipartFormData(); + $formData->add('field', 'value'); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + [], + [], + $formData, + ); + + $rawClient->sendRequest($request); + + $lastRequest = $mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $this->assertEquals('Bearer test-token', $lastRequest->getHeaderLine('Authorization')); + $this->assertStringStartsWith('multipart/form-data; boundary=', $lastRequest->getHeaderLine('Content-Type')); + } + + /** + * Creates a PSR-7 response using discovery, without depending on any specific implementation. + * + * @param int $statusCode + * @param array $headers + * @param string $body + * @return ResponseInterface + */ + private static function createResponse( + int $statusCode = 200, + array $headers = [], + string $body = '', + ): ResponseInterface { + $response = \Http\Discovery\Psr17FactoryDiscovery::findResponseFactory() + ->createResponse($statusCode); + foreach ($headers as $name => $value) { + $response = $response->withHeader($name, $value); + } + if ($body !== '') { + $response = $response->withBody( + \Http\Discovery\Psr17FactoryDiscovery::findStreamFactory() + ->createStream($body), + ); + } + return $response; + } + + + public function testTimeoutOptionIsAccepted(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ); + + // MockHttpClient is not Guzzle/Symfony, so a warning is triggered once. + set_error_handler(static function (int $errno, string $errstr): bool { + return $errno === E_USER_WARNING + && str_contains($errstr, 'Timeout option is not supported'); + }); + + try { + $response = $this->rawClient->sendRequest( + $request, + options: [ + 'timeout' => 3.0 + ] + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + } finally { + restore_error_handler(); + } + } + + public function testClientLevelTimeoutIsAccepted(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(200)); + + $rawClient = new RawClient([ + 'client' => $mockClient, + 'maxRetries' => 0, + 'timeout' => 5.0, + ]); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ); + + set_error_handler(static function (int $errno, string $errstr): bool { + return $errno === E_USER_WARNING + && str_contains($errstr, 'Timeout option is not supported'); + }); + + try { + $response = $rawClient->sendRequest($request); + $this->assertEquals(200, $response->getStatusCode()); + } finally { + restore_error_handler(); + } + } + + public function testPerRequestTimeoutOverridesClientTimeout(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(200)); + + $rawClient = new RawClient([ + 'client' => $mockClient, + 'maxRetries' => 0, + 'timeout' => 5.0, + ]); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ); + + set_error_handler(static function (int $errno, string $errstr): bool { + return $errno === E_USER_WARNING + && str_contains($errstr, 'Timeout option is not supported'); + }); + + try { + $response = $rawClient->sendRequest( + $request, + options: [ + 'timeout' => 1.0 + ] + ); + + $this->assertEquals(200, $response->getStatusCode()); + } finally { + restore_error_handler(); + } + } + + public function testDiscoveryFindsHttpClient(): void + { + // HttpClientBuilder::build() with no client arg uses Psr18ClientDiscovery. + $client = HttpClientBuilder::build(); + $this->assertInstanceOf(\Psr\Http\Client\ClientInterface::class, $client); + } + + public function testDiscoveryFindsFactories(): void + { + $requestFactory = HttpClientBuilder::requestFactory(); + $this->assertInstanceOf(\Psr\Http\Message\RequestFactoryInterface::class, $requestFactory); + + $streamFactory = HttpClientBuilder::streamFactory(); + $this->assertInstanceOf(\Psr\Http\Message\StreamFactoryInterface::class, $streamFactory); + + // Verify they produce usable objects + $request = $requestFactory->createRequest('GET', 'https://example.com'); + $this->assertEquals('GET', $request->getMethod()); + + $stream = $streamFactory->createStream('hello'); + $this->assertEquals('hello', (string) $stream); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/AdditionalPropertiesTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/AdditionalPropertiesTest.php new file mode 100644 index 000000000000..2c32002340e7 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/AdditionalPropertiesTest.php @@ -0,0 +1,76 @@ +name; + } + + /** + * @return string|null + */ + public function getEmail(): ?string + { + return $this->email; + } + + /** + * @param array{ + * name: string, + * email?: string|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->name = $values['name']; + $this->email = $values['email'] ?? null; + } +} + +class AdditionalPropertiesTest extends TestCase +{ + public function testExtraProperties(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'name' => 'john.doe', + 'email' => 'john.doe@example.com', + 'age' => 42 + ], + ); + + $person = Person::fromJson($expectedJson); + $this->assertEquals('john.doe', $person->getName()); + $this->assertEquals('john.doe@example.com', $person->getEmail()); + $this->assertEquals( + [ + 'age' => 42 + ], + $person->getAdditionalProperties(), + ); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/DateArrayTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/DateArrayTest.php new file mode 100644 index 000000000000..e7794d652432 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/DateArrayTest.php @@ -0,0 +1,54 @@ +dates = $values['dates']; + } +} + +class DateArrayTest extends TestCase +{ + public function testDateTimeInArrays(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'dates' => ['2023-01-01', '2023-02-01', '2023-03-01'] + ], + ); + + $object = DateArray::fromJson($expectedJson); + $this->assertInstanceOf(DateTime::class, $object->dates[0], 'dates[0] should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->dates[0]->format('Y-m-d'), 'dates[0] should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->dates[1], 'dates[1] should be a DateTime instance.'); + $this->assertEquals('2023-02-01', $object->dates[1]->format('Y-m-d'), 'dates[1] should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->dates[2], 'dates[2] should be a DateTime instance.'); + $this->assertEquals('2023-03-01', $object->dates[2]->format('Y-m-d'), 'dates[2] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for dates array.'); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/EmptyArrayTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/EmptyArrayTest.php new file mode 100644 index 000000000000..b5f217e01f76 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/EmptyArrayTest.php @@ -0,0 +1,71 @@ + $emptyMapArray + */ + #[JsonProperty('empty_map_array')] + #[ArrayType(['integer' => new Union('string', 'null')])] + public array $emptyMapArray; + + /** + * @var array $emptyDatesArray + */ + #[ArrayType([new Union('date', 'null')])] + #[JsonProperty('empty_dates_array')] + public array $emptyDatesArray; + + /** + * @param array{ + * emptyStringArray: string[], + * emptyMapArray: array, + * emptyDatesArray: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->emptyStringArray = $values['emptyStringArray']; + $this->emptyMapArray = $values['emptyMapArray']; + $this->emptyDatesArray = $values['emptyDatesArray']; + } +} + +class EmptyArrayTest extends TestCase +{ + public function testEmptyArray(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'empty_string_array' => [], + 'empty_map_array' => [], + 'empty_dates_array' => [] + ], + ); + + $object = EmptyArray::fromJson($expectedJson); + $this->assertEmpty($object->emptyStringArray, 'empty_string_array should be empty.'); + $this->assertEmpty($object->emptyMapArray, 'empty_map_array should be empty.'); + $this->assertEmpty($object->emptyDatesArray, 'empty_dates_array should be empty.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for EmptyArraysType.'); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/EnumTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/EnumTest.php new file mode 100644 index 000000000000..72dc6f2cfa00 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/EnumTest.php @@ -0,0 +1,77 @@ +value; + } +} + +class ShapeType extends JsonSerializableType +{ + /** + * @var Shape $shape + */ + #[JsonProperty('shape')] + public Shape $shape; + + /** + * @var Shape[] $shapes + */ + #[ArrayType([Shape::class])] + #[JsonProperty('shapes')] + public array $shapes; + + /** + * @param Shape $shape + * @param Shape[] $shapes + */ + public function __construct( + Shape $shape, + array $shapes, + ) { + $this->shape = $shape; + $this->shapes = $shapes; + } +} + +class EnumTest extends TestCase +{ + public function testEnumSerialization(): void + { + $object = new ShapeType( + Shape::Circle, + [Shape::Square, Shape::Circle, Shape::Triangle] + ); + + $expectedJson = JsonEncoder::encode([ + 'shape' => 'CIRCLE', + 'shapes' => ['SQUARE', 'CIRCLE', 'TRIANGLE'] + ]); + + $actualJson = $object->toJson(); + + $this->assertJsonStringEqualsJsonString( + $expectedJson, + $actualJson, + 'Serialized JSON does not match expected JSON for shape and shapes properties.' + ); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/ExhaustiveTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/ExhaustiveTest.php new file mode 100644 index 000000000000..4c288378b48b --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/ExhaustiveTest.php @@ -0,0 +1,197 @@ +nestedProperty = $values['nestedProperty']; + } +} + +class Type extends JsonSerializableType +{ + /** + * @var Nested nestedType + */ + #[JsonProperty('nested_type')] + public Nested $nestedType; /** + + * @var string $simpleProperty + */ + #[JsonProperty('simple_property')] + public string $simpleProperty; + + /** + * @var DateTime $dateProperty + */ + #[Date(Date::TYPE_DATE)] + #[JsonProperty('date_property')] + public DateTime $dateProperty; + + /** + * @var DateTime $datetimeProperty + */ + #[Date(Date::TYPE_DATETIME)] + #[JsonProperty('datetime_property')] + public DateTime $datetimeProperty; + + /** + * @var array $stringArray + */ + #[ArrayType(['string'])] + #[JsonProperty('string_array')] + public array $stringArray; + + /** + * @var array $mapProperty + */ + #[ArrayType(['string' => 'integer'])] + #[JsonProperty('map_property')] + public array $mapProperty; + + /** + * @var array $objectArray + */ + #[ArrayType(['integer' => new Union(Nested::class, 'null')])] + #[JsonProperty('object_array')] + public array $objectArray; + + /** + * @var array> $nestedArray + */ + #[ArrayType(['integer' => ['integer' => new Union('string', 'null')]])] + #[JsonProperty('nested_array')] + public array $nestedArray; + + /** + * @var array $datesArray + */ + #[ArrayType([new Union('date', 'null')])] + #[JsonProperty('dates_array')] + public array $datesArray; + + /** + * @var string|null $nullableProperty + */ + #[JsonProperty('nullable_property')] + public ?string $nullableProperty; + + /** + * @param array{ + * nestedType: Nested, + * simpleProperty: string, + * dateProperty: DateTime, + * datetimeProperty: DateTime, + * stringArray: array, + * mapProperty: array, + * objectArray: array, + * nestedArray: array>, + * datesArray: array, + * nullableProperty?: string|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nestedType = $values['nestedType']; + $this->simpleProperty = $values['simpleProperty']; + $this->dateProperty = $values['dateProperty']; + $this->datetimeProperty = $values['datetimeProperty']; + $this->stringArray = $values['stringArray']; + $this->mapProperty = $values['mapProperty']; + $this->objectArray = $values['objectArray']; + $this->nestedArray = $values['nestedArray']; + $this->datesArray = $values['datesArray']; + $this->nullableProperty = $values['nullableProperty'] ?? null; + } +} + +class ExhaustiveTest extends TestCase +{ + /** + * Test serialization and deserialization of all types in Type. + */ + public function testExhaustive(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'nested_type' => ['nested_property' => '1995-07-20'], + 'simple_property' => 'Test String', + // Omit 'nullable_property' to test null serialization + 'date_property' => '2023-01-01', + 'datetime_property' => '2023-01-01T12:34:56Z', + 'string_array' => ['one', 'two', 'three'], + 'map_property' => ['key1' => 1, 'key2' => 2], + 'object_array' => [ + 1 => ['nested_property' => '2021-07-20'], + 2 => null, // Testing nullable objects in array + ], + 'nested_array' => [ + 1 => [1 => 'value1', 2 => null], // Testing nullable strings in nested array + 2 => [3 => 'value3', 4 => 'value4'] + ], + 'dates_array' => ['2023-01-01', null, '2023-03-01'] // Testing nullable dates in array> + ], + ); + + $object = Type::fromJson($expectedJson); + + // Check that nullable property is null and not included in JSON + $this->assertNull($object->nullableProperty, 'Nullable property should be null.'); + + // Check date properties + $this->assertInstanceOf(DateTime::class, $object->dateProperty, 'date_property should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->dateProperty->format('Y-m-d'), 'date_property should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->datetimeProperty, 'datetime_property should be a DateTime instance.'); + $this->assertEquals('2023-01-01 12:34:56', $object->datetimeProperty->format('Y-m-d H:i:s'), 'datetime_property should have the correct datetime.'); + + // Check scalar arrays + $this->assertEquals(['one', 'two', 'three'], $object->stringArray, 'string_array should match the original data.'); + $this->assertEquals(['key1' => 1, 'key2' => 2], $object->mapProperty, 'map_property should match the original data.'); + + // Check object array with nullable elements + $this->assertInstanceOf(Nested::class, $object->objectArray[1], 'object_array[1] should be an instance of TestNestedType1.'); + $this->assertEquals('2021-07-20', $object->objectArray[1]->nestedProperty->format('Y-m-d'), 'object_array[1]->nestedProperty should match the original data.'); + $this->assertNull($object->objectArray[2], 'object_array[2] should be null.'); + + // Check nested array with nullable strings + $this->assertEquals('value1', $object->nestedArray[1][1], 'nested_array[1][1] should match the original data.'); + $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); + $this->assertEquals('value3', $object->nestedArray[2][3], 'nested_array[2][3] should match the original data.'); + $this->assertEquals('value4', $object->nestedArray[2][4], 'nested_array[2][4] should match the original data.'); + + // Check dates array with nullable DateTime objects + $this->assertInstanceOf(DateTime::class, $object->datesArray[0], 'dates_array[0] should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->datesArray[0]->format('Y-m-d'), 'dates_array[0] should have the correct date.'); + $this->assertNull($object->datesArray[1], 'dates_array[1] should be null.'); + $this->assertInstanceOf(DateTime::class, $object->datesArray[2], 'dates_array[2] should be a DateTime instance.'); + $this->assertEquals('2023-03-01', $object->datesArray[2]->format('Y-m-d'), 'dates_array[2] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'The serialized JSON does not match the original JSON.'); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/InvalidTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/InvalidTest.php new file mode 100644 index 000000000000..9d845ea113b8 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/InvalidTest.php @@ -0,0 +1,42 @@ +integerProperty = $values['integerProperty']; + } +} + +class InvalidTest extends TestCase +{ + public function testInvalidJsonThrowsException(): void + { + $this->expectException(\TypeError::class); + $json = JsonEncoder::encode( + [ + 'integer_property' => 'not_an_integer' + ], + ); + Invalid::fromJson($json); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/NestedUnionArrayTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/NestedUnionArrayTest.php new file mode 100644 index 000000000000..8fbbeb939f02 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/NestedUnionArrayTest.php @@ -0,0 +1,89 @@ +nestedProperty = $values['nestedProperty']; + } +} + +class NestedUnionArray extends JsonSerializableType +{ + /** + * @var array> $nestedArray + */ + #[ArrayType(['integer' => ['integer' => new Union(UnionObject::class, 'null', 'date')]])] + #[JsonProperty('nested_array')] + public array $nestedArray; + + /** + * @param array{ + * nestedArray: array>, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nestedArray = $values['nestedArray']; + } +} + +class NestedUnionArrayTest extends TestCase +{ + public function testNestedUnionArray(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'nested_array' => [ + 1 => [ + 1 => ['nested_property' => 'Nested One'], + 2 => null, + 4 => '2023-01-02' + ], + 2 => [ + 5 => ['nested_property' => 'Nested Two'], + 7 => '2023-02-02' + ] + ] + ], + ); + + $object = NestedUnionArray::fromJson($expectedJson); + $this->assertInstanceOf(UnionObject::class, $object->nestedArray[1][1], 'nested_array[1][1] should be an instance of Object.'); + $this->assertEquals('Nested One', $object->nestedArray[1][1]->nestedProperty, 'nested_array[1][1]->nestedProperty should match the original data.'); + $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); + $this->assertInstanceOf(DateTime::class, $object->nestedArray[1][4], 'nested_array[1][4] should be a DateTime instance.'); + $this->assertEquals('2023-01-02T00:00:00+00:00', $object->nestedArray[1][4]->format(Constant::DateTimeFormat), 'nested_array[1][4] should have the correct datetime.'); + $this->assertInstanceOf(UnionObject::class, $object->nestedArray[2][5], 'nested_array[2][5] should be an instance of Object.'); + $this->assertEquals('Nested Two', $object->nestedArray[2][5]->nestedProperty, 'nested_array[2][5]->nestedProperty should match the original data.'); + $this->assertInstanceOf(DateTime::class, $object->nestedArray[2][7], 'nested_array[1][4] should be a DateTime instance.'); + $this->assertEquals('2023-02-02', $object->nestedArray[2][7]->format('Y-m-d'), 'nested_array[1][4] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nested_array.'); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/NullPropertyTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/NullPropertyTest.php new file mode 100644 index 000000000000..ce20a2442825 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/NullPropertyTest.php @@ -0,0 +1,53 @@ +nonNullProperty = $values['nonNullProperty']; + $this->nullProperty = $values['nullProperty'] ?? null; + } +} + +class NullPropertyTest extends TestCase +{ + public function testNullPropertiesAreOmitted(): void + { + $object = new NullProperty( + [ + "nonNullProperty" => "Test String", + "nullProperty" => null + ] + ); + + $serialized = $object->jsonSerialize(); + $this->assertArrayHasKey('non_null_property', $serialized, 'non_null_property should be present in the serialized JSON.'); + $this->assertArrayNotHasKey('null_property', $serialized, 'null_property should be omitted from the serialized JSON.'); + $this->assertEquals('Test String', $serialized['non_null_property'], 'non_null_property should have the correct value.'); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/NullableArrayTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/NullableArrayTest.php new file mode 100644 index 000000000000..d1749c434a4c --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/NullableArrayTest.php @@ -0,0 +1,49 @@ + $nullableStringArray + */ + #[ArrayType([new Union('string', 'null')])] + #[JsonProperty('nullable_string_array')] + public array $nullableStringArray; + + /** + * @param array{ + * nullableStringArray: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nullableStringArray = $values['nullableStringArray']; + } +} + +class NullableArrayTest extends TestCase +{ + public function testNullableArray(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'nullable_string_array' => ['one', null, 'three'] + ], + ); + + $object = NullableArray::fromJson($expectedJson); + $this->assertEquals(['one', null, 'three'], $object->nullableStringArray, 'nullable_string_array should match the original data.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nullable_string_array.'); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/ScalarTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/ScalarTest.php new file mode 100644 index 000000000000..ad4db0251bb5 --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/ScalarTest.php @@ -0,0 +1,116 @@ + $intFloatArray + */ + #[ArrayType([new Union('integer', 'float')])] + #[JsonProperty('int_float_array')] + public array $intFloatArray; + + /** + * @var array $floatArray + */ + #[ArrayType(['float'])] + #[JsonProperty('float_array')] + public array $floatArray; + + /** + * @var bool|null $nullableBooleanProperty + */ + #[JsonProperty('nullable_boolean_property')] + public ?bool $nullableBooleanProperty; + + /** + * @param array{ + * integerProperty: int, + * floatProperty: float, + * otherFloatProperty: float, + * booleanProperty: bool, + * stringProperty: string, + * intFloatArray: array, + * floatArray: array, + * nullableBooleanProperty?: bool|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->integerProperty = $values['integerProperty']; + $this->floatProperty = $values['floatProperty']; + $this->otherFloatProperty = $values['otherFloatProperty']; + $this->booleanProperty = $values['booleanProperty']; + $this->stringProperty = $values['stringProperty']; + $this->intFloatArray = $values['intFloatArray']; + $this->floatArray = $values['floatArray']; + $this->nullableBooleanProperty = $values['nullableBooleanProperty'] ?? null; + } +} + +class ScalarTest extends TestCase +{ + public function testAllScalarTypesIncludingFloat(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'integer_property' => 42, + 'float_property' => 3.14159, + 'other_float_property' => 3, + 'boolean_property' => true, + 'string_property' => 'Hello, World!', + 'int_float_array' => [1, 2.5, 3, 4.75], + 'float_array' => [1, 2, 3, 4] // Ensure we handle "integer-looking" floats + ], + ); + + $object = Scalar::fromJson($expectedJson); + $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); + $this->assertEquals(3.14159, $object->floatProperty, 'float_property should be 3.14159.'); + $this->assertTrue($object->booleanProperty, 'boolean_property should be true.'); + $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); + $this->assertNull($object->nullableBooleanProperty, 'nullable_boolean_property should be null.'); + $this->assertEquals([1, 2.5, 3, 4.75], $object->intFloatArray, 'int_float_array should match the original data.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTest.'); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/TraitTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/TraitTest.php new file mode 100644 index 000000000000..e18f06d4191b --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/TraitTest.php @@ -0,0 +1,60 @@ +integerProperty = $values['integerProperty']; + $this->stringProperty = $values['stringProperty']; + } +} + +class TraitTest extends TestCase +{ + public function testTraitPropertyAndString(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'integer_property' => 42, + 'string_property' => 'Hello, World!', + ], + ); + + $object = TypeWithTrait::fromJson($expectedJson); + $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); + $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTestWithTrait.'); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionArrayTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionArrayTest.php new file mode 100644 index 000000000000..de20cf9fde1b --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionArrayTest.php @@ -0,0 +1,57 @@ + $mixedDates + */ + #[ArrayType(['integer' => new Union('datetime', 'string', 'null')])] + #[JsonProperty('mixed_dates')] + public array $mixedDates; + + /** + * @param array{ + * mixedDates: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->mixedDates = $values['mixedDates']; + } +} + +class UnionArrayTest extends TestCase +{ + public function testUnionArray(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'mixed_dates' => [ + 1 => '2023-01-01T12:00:00Z', + 2 => null, + 3 => 'Some String' + ] + ], + ); + + $object = UnionArray::fromJson($expectedJson); + $this->assertInstanceOf(DateTime::class, $object->mixedDates[1], 'mixed_dates[1] should be a DateTime instance.'); + $this->assertEquals('2023-01-01 12:00:00', $object->mixedDates[1]->format('Y-m-d H:i:s'), 'mixed_dates[1] should have the correct datetime.'); + $this->assertNull($object->mixedDates[2], 'mixed_dates[2] should be null.'); + $this->assertEquals('Some String', $object->mixedDates[3], 'mixed_dates[3] should be "Some String".'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for mixed_dates.'); + } +} diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionPropertyTest.php b/seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionPropertyTest.php new file mode 100644 index 000000000000..f733062cfabc --- /dev/null +++ b/seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionPropertyTest.php @@ -0,0 +1,111 @@ + 'integer'], UnionProperty::class)] + #[JsonProperty('complexUnion')] + public mixed $complexUnion; + + /** + * @param array{ + * complexUnion: string|int|null|array|UnionProperty + * } $values + */ + public function __construct( + array $values, + ) { + $this->complexUnion = $values['complexUnion']; + } +} + +class UnionPropertyTest extends TestCase +{ + public function testWithMapOfIntToInt(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'complexUnion' => [1 => 100, 2 => 200] + ], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsArray($object->complexUnion, 'complexUnion should be an array.'); + $this->assertEquals([1 => 100, 2 => 200], $object->complexUnion, 'complexUnion should match the original map of int => int.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithNestedUnionPropertyType(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'complexUnion' => new UnionProperty( + [ + 'complexUnion' => 'Nested String' + ] + ) + ], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertInstanceOf(UnionProperty::class, $object->complexUnion, 'complexUnion should be an instance of UnionPropertyType.'); + $this->assertEquals('Nested String', $object->complexUnion->complexUnion, 'Nested complexUnion should match the original value.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithNull(): void + { + $expectedJson = JsonEncoder::encode( + [], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertNull($object->complexUnion, 'complexUnion should be null.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithInteger(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'complexUnion' => 42 + ], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsInt($object->complexUnion, 'complexUnion should be an integer.'); + $this->assertEquals(42, $object->complexUnion, 'complexUnion should match the original integer.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithString(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'complexUnion' => 'Some String' + ], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsString($object->complexUnion, 'complexUnion should be a string.'); + $this->assertEquals('Some String', $object->complexUnion, 'complexUnion should match the original string.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } +} diff --git a/test-definitions/fern/apis/basic-auth-optional/definition/api.yml b/test-definitions/fern/apis/basic-auth-optional/definition/api.yml new file mode 100644 index 000000000000..8b1d72b0b769 --- /dev/null +++ b/test-definitions/fern/apis/basic-auth-optional/definition/api.yml @@ -0,0 +1,12 @@ +name: basic-auth-optional +auth: Basic +auth-schemes: + Basic: + scheme: basic + username: + name: username + password: + name: password + omit: true +error-discrimination: + strategy: status-code diff --git a/test-definitions/fern/apis/basic-auth-optional/definition/basic-auth.yml b/test-definitions/fern/apis/basic-auth-optional/definition/basic-auth.yml new file mode 100644 index 000000000000..6a21fd56c9f9 --- /dev/null +++ b/test-definitions/fern/apis/basic-auth-optional/definition/basic-auth.yml @@ -0,0 +1,39 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/fern-api/fern/main/fern.schema.json + +imports: + errors: ./errors.yml + +service: + auth: false + base-path: "" + endpoints: + getWithBasicAuth: + auth: true + docs: GET request with basic auth scheme + path: /basic-auth + method: GET + response: + boolean + examples: + - response: + body: true + errors: + - errors.UnauthorizedRequest + + postWithBasicAuth: + auth: true + docs: POST request with basic auth scheme + path: /basic-auth + method: POST + request: + name: PostWithBasicAuth + body: unknown + response: boolean + examples: + - request: + key: "value" + response: + body: true + errors: + - errors.UnauthorizedRequest + - errors.BadRequest diff --git a/test-definitions/fern/apis/basic-auth-optional/definition/errors.yml b/test-definitions/fern/apis/basic-auth-optional/definition/errors.yml new file mode 100644 index 000000000000..cdd6a9667031 --- /dev/null +++ b/test-definitions/fern/apis/basic-auth-optional/definition/errors.yml @@ -0,0 +1,11 @@ +errors: + UnauthorizedRequest: + status-code: 401 + type: UnauthorizedRequestErrorBody + BadRequest: + status-code: 400 + +types: + UnauthorizedRequestErrorBody: + properties: + message: string diff --git a/test-definitions/fern/apis/basic-auth-optional/generators.yml b/test-definitions/fern/apis/basic-auth-optional/generators.yml new file mode 100644 index 000000000000..b30d6ed97cd2 --- /dev/null +++ b/test-definitions/fern/apis/basic-auth-optional/generators.yml @@ -0,0 +1,22 @@ +# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json +groups: + php-sdk: + generators: + - name: fernapi/fern-php-sdk + version: latest + ir-version: v61 + github: + token: ${GITHUB_TOKEN} + mode: push + uri: fern-api/php-sdk-tests + branch: basic-auth-optional + go-sdk: + generators: + - name: fernapi/fern-go-sdk + version: latest + ir-version: v61 + github: + token: ${GITHUB_TOKEN} + mode: push + uri: fern-api/go-sdk-tests + branch: basic-auth-optional From 4cdca7e1bab6803368d5af8fa582c18e214de979 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Wed, 1 Apr 2026 15:54:41 +0000 Subject: [PATCH 02/24] fix(php-sdk): use per-field omit checks and constructor optionality instead of coarse eitherOmitted flag Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/root-client/RootClientGenerator.ts | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index 6ea201ae4301..011e580b9474 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -358,23 +358,29 @@ export class RootClientGenerator extends FileGenerator; - const eitherOmitted = scheme.usernameOmit === true || scheme.passwordOmit === true; + const usernameOmitted = scheme.usernameOmit === true; + const passwordOmitted = scheme.passwordOmit === true; if (isAuthOptional || basicAuthSchemes.length > 1) { const controlFlowKeyword = i === 0 ? "if" : "else if"; - const condition = eitherOmitted - ? `$${usernameName} !== null || $${passwordName} !== null` - : `$${usernameName} !== null && $${passwordName} !== null`; + // Per-field condition: required fields must be present, omittable fields are always satisfied + let condition: string; + if (!usernameOmitted && !passwordOmitted) { + condition = `$${usernameName} !== null && $${passwordName} !== null`; + } else if (usernameOmitted && passwordOmitted) { + condition = `$${usernameName} !== null || $${passwordName} !== null`; + } else if (usernameOmitted) { + condition = `$${passwordName} !== null`; + } else { + condition = `$${usernameName} !== null`; + } writer.controlFlow(controlFlowKeyword, php.codeblock(condition)); } - if (eitherOmitted) { - writer.writeLine( - `$defaultHeaders['Authorization'] = "Basic " . base64_encode(($${usernameName} ?? "") . ":" . ($${passwordName} ?? ""));` - ); - } else { - writer.writeLine( - `$defaultHeaders['Authorization'] = "Basic " . base64_encode($${usernameName} . ":" . $${passwordName});` - ); - } + // Per-field null coalescing: only omittable fields get ?? "" fallback + const usernameExpr = usernameOmitted ? `($${usernameName} ?? "")` : `$${usernameName}`; + const passwordExpr = passwordOmitted ? `($${passwordName} ?? "")` : `$${passwordName}`; + writer.writeLine( + `$defaultHeaders['Authorization'] = "Basic " . base64_encode(${usernameExpr} . ":" . ${passwordExpr});` + ); if (isAuthOptional || basicAuthSchemes.length > 1) { writer.endControlFlow(); } @@ -606,26 +612,30 @@ export class RootClientGenerator extends FileGenerator; + const usernameIsOptional = isOptional || schemeRecord.usernameOmit === true; + const passwordIsOptional = isOptional || schemeRecord.passwordOmit === true; return [ { name: username, docs: this.getAuthParameterDocs({ docs: scheme.docs, name: username }), - isOptional, + isOptional: usernameIsOptional, typeReference: this.getAuthParameterTypeReference({ typeReference: STRING_TYPE_REFERENCE, envVar: scheme.usernameEnvVar, - isOptional + isOptional: usernameIsOptional }), environmentVariable: scheme.usernameEnvVar }, { name: password, - docs: this.getAuthParameterDocs({ docs: scheme.docs, name: username }), - isOptional, + docs: this.getAuthParameterDocs({ docs: scheme.docs, name: password }), + isOptional: passwordIsOptional, typeReference: this.getAuthParameterTypeReference({ typeReference: STRING_TYPE_REFERENCE, envVar: scheme.passwordEnvVar, - isOptional + isOptional: passwordIsOptional }), environmentVariable: scheme.passwordEnvVar } From 8ed60522fca93358dbb3ecd302fb39387ef4dad7 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:20:28 +0000 Subject: [PATCH 03/24] fix(php-sdk): regenerate seed output for basic-auth-optional (fix password param docs) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- seed/php-sdk/basic-auth-optional/src/SeedClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/seed/php-sdk/basic-auth-optional/src/SeedClient.php b/seed/php-sdk/basic-auth-optional/src/SeedClient.php index b94c5065f3ed..197e6664136c 100644 --- a/seed/php-sdk/basic-auth-optional/src/SeedClient.php +++ b/seed/php-sdk/basic-auth-optional/src/SeedClient.php @@ -31,7 +31,7 @@ class SeedClient /** * @param string $username The username to use for authentication. - * @param string $password The username to use for authentication. + * @param string $password The password to use for authentication. * @param ?array{ * baseUrl?: string, * client?: ClientInterface, From d71fe394dcd72aa04a500da57532d9a4ed48eba4 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Thu, 2 Apr 2026 00:18:25 +0000 Subject: [PATCH 04/24] fix(php-sdk): remove omitted fields entirely from constructor params, use empty string internally Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/root-client/RootClientGenerator.ts | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index 011e580b9474..5258c138899c 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -362,22 +362,22 @@ export class RootClientGenerator extends FileGenerator 1) { const controlFlowKeyword = i === 0 ? "if" : "else if"; - // Per-field condition: required fields must be present, omittable fields are always satisfied + // Condition: only require non-omitted fields to be present let condition: string; if (!usernameOmitted && !passwordOmitted) { condition = `$${usernameName} !== null && $${passwordName} !== null`; - } else if (usernameOmitted && passwordOmitted) { - condition = `$${usernameName} !== null || $${passwordName} !== null`; - } else if (usernameOmitted) { + } else if (usernameOmitted && !passwordOmitted) { condition = `$${passwordName} !== null`; - } else { + } else if (!usernameOmitted && passwordOmitted) { condition = `$${usernameName} !== null`; + } else { + condition = `true`; } writer.controlFlow(controlFlowKeyword, php.codeblock(condition)); } - // Per-field null coalescing: only omittable fields get ?? "" fallback - const usernameExpr = usernameOmitted ? `($${usernameName} ?? "")` : `$${usernameName}`; - const passwordExpr = passwordOmitted ? `($${passwordName} ?? "")` : `$${passwordName}`; + // Omitted fields use empty string directly + const usernameExpr = usernameOmitted ? `""` : `$${usernameName}`; + const passwordExpr = passwordOmitted ? `""` : `$${passwordName}`; writer.writeLine( `$defaultHeaders['Authorization'] = "Basic " . base64_encode(${usernameExpr} . ":" . ${passwordExpr});` ); @@ -612,34 +612,38 @@ export class RootClientGenerator extends FileGenerator; - const usernameIsOptional = isOptional || schemeRecord.usernameOmit === true; - const passwordIsOptional = isOptional || schemeRecord.passwordOmit === true; - return [ - { + const usernameOmitted = schemeRecord.usernameOmit === true; + const passwordOmitted = schemeRecord.passwordOmit === true; + const params: ConstructorParameter[] = []; + if (!usernameOmitted) { + params.push({ name: username, docs: this.getAuthParameterDocs({ docs: scheme.docs, name: username }), - isOptional: usernameIsOptional, + isOptional, typeReference: this.getAuthParameterTypeReference({ typeReference: STRING_TYPE_REFERENCE, envVar: scheme.usernameEnvVar, - isOptional: usernameIsOptional + isOptional }), environmentVariable: scheme.usernameEnvVar - }, - { + }); + } + if (!passwordOmitted) { + params.push({ name: password, docs: this.getAuthParameterDocs({ docs: scheme.docs, name: password }), - isOptional: passwordIsOptional, + isOptional, typeReference: this.getAuthParameterTypeReference({ typeReference: STRING_TYPE_REFERENCE, envVar: scheme.passwordEnvVar, - isOptional: passwordIsOptional + isOptional }), environmentVariable: scheme.passwordEnvVar - } - ]; + }); + } + return params; } case "header": { const name = this.context.getParameterName(scheme.name.name); From 1bfe86e1acfe1909d61a795777c6e276af48142c Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:25:09 +0000 Subject: [PATCH 05/24] fix(php-sdk): skip auth header when both fields omitted and auth is non-mandatory Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- generators/php/sdk/src/root-client/RootClientGenerator.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index 5258c138899c..164bde60fd60 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -371,7 +371,8 @@ export class RootClientGenerator extends FileGenerator Date: Thu, 2 Apr 2026 17:34:36 +0000 Subject: [PATCH 06/24] fix(php-sdk): use isFirstBlock to prevent else if without preceding if when first scheme skipped Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/root-client/RootClientGenerator.ts | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index 164bde60fd60..f5935ba4626e 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -349,6 +349,7 @@ export class RootClientGenerator extends FileGenerator 0) { const isAuthOptional = !this.context.ir.sdkConfig.isAuthMandatory; + let isFirstBlock = true; for (let i = 0; i < basicAuthSchemes.length; i++) { const basicAuthScheme = basicAuthSchemes[i]; if (basicAuthScheme == null) { @@ -360,22 +361,23 @@ export class RootClientGenerator extends FileGenerator; const usernameOmitted = scheme.usernameOmit === true; const passwordOmitted = scheme.passwordOmit === true; + // Condition: only require non-omitted fields to be present + let condition: string; + if (!usernameOmitted && !passwordOmitted) { + condition = `$${usernameName} !== null && $${passwordName} !== null`; + } else if (usernameOmitted && !passwordOmitted) { + condition = `$${passwordName} !== null`; + } else if (!usernameOmitted && passwordOmitted) { + condition = `$${usernameName} !== null`; + } else { + // Both fields omitted — skip auth header entirely when auth is optional + continue; + } if (isAuthOptional || basicAuthSchemes.length > 1) { - const controlFlowKeyword = i === 0 ? "if" : "else if"; - // Condition: only require non-omitted fields to be present - let condition: string; - if (!usernameOmitted && !passwordOmitted) { - condition = `$${usernameName} !== null && $${passwordName} !== null`; - } else if (usernameOmitted && !passwordOmitted) { - condition = `$${passwordName} !== null`; - } else if (!usernameOmitted && passwordOmitted) { - condition = `$${usernameName} !== null`; - } else { - // Both fields omitted — skip auth header entirely when auth is optional - continue; - } + const controlFlowKeyword = isFirstBlock ? "if" : "else if"; writer.controlFlow(controlFlowKeyword, php.codeblock(condition)); } + isFirstBlock = false; // Omitted fields use empty string directly const usernameExpr = usernameOmitted ? `""` : `$${usernameName}`; const passwordExpr = passwordOmitted ? `""` : `$${passwordName}`; From b04497f71db1b420c1e7146f8e81ae061d26fd5a Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:20:26 +0000 Subject: [PATCH 07/24] fix(php-sdk): use 'omit' instead of 'optional' in versions.yml changelog and code comment Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../php/sdk/src/root-client/RootClientGenerator.ts | 2 +- generators/php/sdk/versions.yml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index f5935ba4626e..d33106bf65fb 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -370,7 +370,7 @@ export class RootClientGenerator extends FileGenerator 1) { diff --git a/generators/php/sdk/versions.yml b/generators/php/sdk/versions.yml index 7cb820f688d1..6c448e78e12b 100644 --- a/generators/php/sdk/versions.yml +++ b/generators/php/sdk/versions.yml @@ -2,11 +2,11 @@ - version: 2.3.2 changelogEntry: - summary: | - Support optional username and password in basic auth. The SDK now accepts - username-only, password-only, or both credentials. Missing fields are treated - as empty strings (e.g., username-only encodes `username:`, password-only - encodes `:password`). When neither is provided, the Authorization header is - omitted entirely. + Support omitting username or password from basic auth when configured via + `usernameOmit` or `passwordOmit` in the IR. Omitted fields are removed from + the SDK's public API and treated as empty strings internally (e.g., omitting + password encodes `username:`, omitting username encodes `:password`). When + both are omitted, the Authorization header is skipped entirely. type: feat createdAt: "2026-04-02" irVersion: 62 From 487b1efc1c5ca7b973747ed750c754316c007196 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:41:27 +0000 Subject: [PATCH 08/24] refactor: rename basic-auth-optional fixture to basic-auth-pw-omitted Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../type_errors_UnauthorizedRequestErrorBody.json | 0 .../.fern/metadata.json | 0 .../.github/workflows/ci.yml | 0 .../{basic-auth-optional => basic-auth-pw-omitted}/.gitignore | 0 .../{basic-auth-optional => basic-auth-pw-omitted}/README.md | 0 .../composer.json | 0 .../phpstan.neon | 0 .../phpunit.xml | 0 .../reference.md | 0 .../snippet.json | 0 .../src/BasicAuth/BasicAuthClient.php | 0 .../src/Core/Client/BaseApiRequest.php | 0 .../src/Core/Client/HttpClientBuilder.php | 0 .../src/Core/Client/HttpMethod.php | 0 .../src/Core/Client/MockHttpClient.php | 0 .../src/Core/Client/RawClient.php | 0 .../src/Core/Client/RetryDecoratingClient.php | 0 .../src/Core/Json/JsonApiRequest.php | 0 .../src/Core/Json/JsonDecoder.php | 0 .../src/Core/Json/JsonDeserializer.php | 0 .../src/Core/Json/JsonEncoder.php | 0 .../src/Core/Json/JsonProperty.php | 0 .../src/Core/Json/JsonSerializableType.php | 0 .../src/Core/Json/JsonSerializer.php | 0 .../src/Core/Json/Utils.php | 0 .../src/Core/Multipart/MultipartApiRequest.php | 0 .../src/Core/Multipart/MultipartFormData.php | 0 .../src/Core/Multipart/MultipartFormDataPart.php | 0 .../src/Core/Types/ArrayType.php | 0 .../src/Core/Types/Constant.php | 0 .../src/Core/Types/Date.php | 0 .../src/Core/Types/Union.php | 0 .../src/Errors/Types/UnauthorizedRequestErrorBody.php | 0 .../src/Exceptions/SeedApiException.php | 0 .../src/Exceptions/SeedException.php | 0 .../src/SeedClient.php | 0 .../src/Utils/File.php | 0 .../src/dynamic-snippets/example0/snippet.php | 0 .../src/dynamic-snippets/example1/snippet.php | 0 .../src/dynamic-snippets/example2/snippet.php | 0 .../src/dynamic-snippets/example3/snippet.php | 0 .../src/dynamic-snippets/example4/snippet.php | 0 .../src/dynamic-snippets/example5/snippet.php | 0 .../src/dynamic-snippets/example6/snippet.php | 0 .../tests/Core/Client/RawClientTest.php | 0 .../tests/Core/Json/AdditionalPropertiesTest.php | 0 .../tests/Core/Json/DateArrayTest.php | 0 .../tests/Core/Json/EmptyArrayTest.php | 0 .../tests/Core/Json/EnumTest.php | 0 .../tests/Core/Json/ExhaustiveTest.php | 0 .../tests/Core/Json/InvalidTest.php | 0 .../tests/Core/Json/NestedUnionArrayTest.php | 0 .../tests/Core/Json/NullPropertyTest.php | 0 .../tests/Core/Json/NullableArrayTest.php | 0 .../tests/Core/Json/ScalarTest.php | 0 .../tests/Core/Json/TraitTest.php | 0 .../tests/Core/Json/UnionArrayTest.php | 0 .../tests/Core/Json/UnionPropertyTest.php | 0 .../definition/api.yml | 2 +- .../definition/basic-auth.yml | 0 .../definition/errors.yml | 0 .../generators.yml | 4 ++-- 62 files changed, 3 insertions(+), 3 deletions(-) rename packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/{basic-auth-optional => basic-auth-pw-omitted}/type_errors_UnauthorizedRequestErrorBody.json (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/.fern/metadata.json (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/.github/workflows/ci.yml (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/.gitignore (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/README.md (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/composer.json (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/phpstan.neon (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/phpunit.xml (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/reference.md (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/snippet.json (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/BasicAuth/BasicAuthClient.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Client/BaseApiRequest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Client/HttpClientBuilder.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Client/HttpMethod.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Client/MockHttpClient.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Client/RawClient.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Client/RetryDecoratingClient.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Json/JsonApiRequest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Json/JsonDecoder.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Json/JsonDeserializer.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Json/JsonEncoder.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Json/JsonProperty.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Json/JsonSerializableType.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Json/JsonSerializer.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Json/Utils.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Multipart/MultipartApiRequest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Multipart/MultipartFormData.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Multipart/MultipartFormDataPart.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Types/ArrayType.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Types/Constant.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Types/Date.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Core/Types/Union.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Errors/Types/UnauthorizedRequestErrorBody.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Exceptions/SeedApiException.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Exceptions/SeedException.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/SeedClient.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/Utils/File.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/dynamic-snippets/example0/snippet.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/dynamic-snippets/example1/snippet.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/dynamic-snippets/example2/snippet.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/dynamic-snippets/example3/snippet.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/dynamic-snippets/example4/snippet.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/dynamic-snippets/example5/snippet.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/src/dynamic-snippets/example6/snippet.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Client/RawClientTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/AdditionalPropertiesTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/DateArrayTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/EmptyArrayTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/EnumTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/ExhaustiveTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/InvalidTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/NestedUnionArrayTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/NullPropertyTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/NullableArrayTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/ScalarTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/TraitTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/UnionArrayTest.php (100%) rename seed/php-sdk/{basic-auth-optional => basic-auth-pw-omitted}/tests/Core/Json/UnionPropertyTest.php (100%) rename test-definitions/fern/apis/{basic-auth-optional => basic-auth-pw-omitted}/definition/api.yml (86%) rename test-definitions/fern/apis/{basic-auth-optional => basic-auth-pw-omitted}/definition/basic-auth.yml (100%) rename test-definitions/fern/apis/{basic-auth-optional => basic-auth-pw-omitted}/definition/errors.yml (100%) rename test-definitions/fern/apis/{basic-auth-optional => basic-auth-pw-omitted}/generators.yml (86%) diff --git a/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/basic-auth-optional/type_errors_UnauthorizedRequestErrorBody.json b/packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/basic-auth-pw-omitted/type_errors_UnauthorizedRequestErrorBody.json similarity index 100% rename from packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/basic-auth-optional/type_errors_UnauthorizedRequestErrorBody.json rename to packages/cli/fern-definition/ir-to-jsonschema/src/__test__/__snapshots__/basic-auth-pw-omitted/type_errors_UnauthorizedRequestErrorBody.json diff --git a/seed/php-sdk/basic-auth-optional/.fern/metadata.json b/seed/php-sdk/basic-auth-pw-omitted/.fern/metadata.json similarity index 100% rename from seed/php-sdk/basic-auth-optional/.fern/metadata.json rename to seed/php-sdk/basic-auth-pw-omitted/.fern/metadata.json diff --git a/seed/php-sdk/basic-auth-optional/.github/workflows/ci.yml b/seed/php-sdk/basic-auth-pw-omitted/.github/workflows/ci.yml similarity index 100% rename from seed/php-sdk/basic-auth-optional/.github/workflows/ci.yml rename to seed/php-sdk/basic-auth-pw-omitted/.github/workflows/ci.yml diff --git a/seed/php-sdk/basic-auth-optional/.gitignore b/seed/php-sdk/basic-auth-pw-omitted/.gitignore similarity index 100% rename from seed/php-sdk/basic-auth-optional/.gitignore rename to seed/php-sdk/basic-auth-pw-omitted/.gitignore diff --git a/seed/php-sdk/basic-auth-optional/README.md b/seed/php-sdk/basic-auth-pw-omitted/README.md similarity index 100% rename from seed/php-sdk/basic-auth-optional/README.md rename to seed/php-sdk/basic-auth-pw-omitted/README.md diff --git a/seed/php-sdk/basic-auth-optional/composer.json b/seed/php-sdk/basic-auth-pw-omitted/composer.json similarity index 100% rename from seed/php-sdk/basic-auth-optional/composer.json rename to seed/php-sdk/basic-auth-pw-omitted/composer.json diff --git a/seed/php-sdk/basic-auth-optional/phpstan.neon b/seed/php-sdk/basic-auth-pw-omitted/phpstan.neon similarity index 100% rename from seed/php-sdk/basic-auth-optional/phpstan.neon rename to seed/php-sdk/basic-auth-pw-omitted/phpstan.neon diff --git a/seed/php-sdk/basic-auth-optional/phpunit.xml b/seed/php-sdk/basic-auth-pw-omitted/phpunit.xml similarity index 100% rename from seed/php-sdk/basic-auth-optional/phpunit.xml rename to seed/php-sdk/basic-auth-pw-omitted/phpunit.xml diff --git a/seed/php-sdk/basic-auth-optional/reference.md b/seed/php-sdk/basic-auth-pw-omitted/reference.md similarity index 100% rename from seed/php-sdk/basic-auth-optional/reference.md rename to seed/php-sdk/basic-auth-pw-omitted/reference.md diff --git a/seed/php-sdk/basic-auth-optional/snippet.json b/seed/php-sdk/basic-auth-pw-omitted/snippet.json similarity index 100% rename from seed/php-sdk/basic-auth-optional/snippet.json rename to seed/php-sdk/basic-auth-pw-omitted/snippet.json diff --git a/seed/php-sdk/basic-auth-optional/src/BasicAuth/BasicAuthClient.php b/seed/php-sdk/basic-auth-pw-omitted/src/BasicAuth/BasicAuthClient.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/BasicAuth/BasicAuthClient.php rename to seed/php-sdk/basic-auth-pw-omitted/src/BasicAuth/BasicAuthClient.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/BaseApiRequest.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/BaseApiRequest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Client/BaseApiRequest.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/BaseApiRequest.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/HttpClientBuilder.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/HttpClientBuilder.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Client/HttpClientBuilder.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/HttpClientBuilder.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/HttpMethod.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/HttpMethod.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Client/HttpMethod.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/HttpMethod.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/MockHttpClient.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/MockHttpClient.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Client/MockHttpClient.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/MockHttpClient.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/RawClient.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RawClient.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Client/RawClient.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RawClient.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Client/RetryDecoratingClient.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RetryDecoratingClient.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Client/RetryDecoratingClient.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RetryDecoratingClient.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonApiRequest.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonApiRequest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Json/JsonApiRequest.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonApiRequest.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDecoder.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDecoder.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDeserializer.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Json/JsonDeserializer.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonEncoder.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonEncoder.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Json/JsonEncoder.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonEncoder.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonProperty.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonProperty.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Json/JsonProperty.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonProperty.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonSerializableType.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializableType.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Json/JsonSerializableType.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializableType.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/JsonSerializer.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Json/JsonSerializer.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Json/Utils.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/Utils.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Json/Utils.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/Utils.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartApiRequest.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartApiRequest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartApiRequest.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartApiRequest.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormData.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormData.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormData.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormData.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormDataPart.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormDataPart.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Multipart/MultipartFormDataPart.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormDataPart.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Types/ArrayType.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/ArrayType.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Types/ArrayType.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/ArrayType.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Types/Constant.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Constant.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Types/Constant.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Constant.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Types/Date.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Date.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Types/Date.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Date.php diff --git a/seed/php-sdk/basic-auth-optional/src/Core/Types/Union.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Union.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Core/Types/Union.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Union.php diff --git a/seed/php-sdk/basic-auth-optional/src/Errors/Types/UnauthorizedRequestErrorBody.php b/seed/php-sdk/basic-auth-pw-omitted/src/Errors/Types/UnauthorizedRequestErrorBody.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Errors/Types/UnauthorizedRequestErrorBody.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Errors/Types/UnauthorizedRequestErrorBody.php diff --git a/seed/php-sdk/basic-auth-optional/src/Exceptions/SeedApiException.php b/seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedApiException.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Exceptions/SeedApiException.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedApiException.php diff --git a/seed/php-sdk/basic-auth-optional/src/Exceptions/SeedException.php b/seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedException.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Exceptions/SeedException.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedException.php diff --git a/seed/php-sdk/basic-auth-optional/src/SeedClient.php b/seed/php-sdk/basic-auth-pw-omitted/src/SeedClient.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/SeedClient.php rename to seed/php-sdk/basic-auth-pw-omitted/src/SeedClient.php diff --git a/seed/php-sdk/basic-auth-optional/src/Utils/File.php b/seed/php-sdk/basic-auth-pw-omitted/src/Utils/File.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/Utils/File.php rename to seed/php-sdk/basic-auth-pw-omitted/src/Utils/File.php diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example0/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example0/snippet.php rename to seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example1/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example1/snippet.php rename to seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example2/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example2/snippet.php rename to seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example3/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example3/snippet.php rename to seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example4/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example4/snippet.php rename to seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example5/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example5/snippet.php rename to seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php diff --git a/seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example6/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/src/dynamic-snippets/example6/snippet.php rename to seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Client/RawClientTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Client/RawClientTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Client/RawClientTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Client/RawClientTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/AdditionalPropertiesTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/AdditionalPropertiesTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/AdditionalPropertiesTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/AdditionalPropertiesTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/DateArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/DateArrayTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/DateArrayTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/DateArrayTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/EmptyArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EmptyArrayTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/EmptyArrayTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EmptyArrayTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/EnumTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EnumTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/EnumTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EnumTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/ExhaustiveTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ExhaustiveTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/ExhaustiveTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ExhaustiveTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/InvalidTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/InvalidTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/InvalidTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/InvalidTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/NestedUnionArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NestedUnionArrayTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/NestedUnionArrayTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NestedUnionArrayTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/NullPropertyTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullPropertyTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/NullPropertyTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullPropertyTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/NullableArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullableArrayTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/NullableArrayTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullableArrayTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/ScalarTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ScalarTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/ScalarTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ScalarTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/TraitTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/TraitTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/TraitTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/TraitTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionArrayTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionArrayTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionArrayTest.php diff --git a/seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionPropertyTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionPropertyTest.php similarity index 100% rename from seed/php-sdk/basic-auth-optional/tests/Core/Json/UnionPropertyTest.php rename to seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionPropertyTest.php diff --git a/test-definitions/fern/apis/basic-auth-optional/definition/api.yml b/test-definitions/fern/apis/basic-auth-pw-omitted/definition/api.yml similarity index 86% rename from test-definitions/fern/apis/basic-auth-optional/definition/api.yml rename to test-definitions/fern/apis/basic-auth-pw-omitted/definition/api.yml index 8b1d72b0b769..db01794de599 100644 --- a/test-definitions/fern/apis/basic-auth-optional/definition/api.yml +++ b/test-definitions/fern/apis/basic-auth-pw-omitted/definition/api.yml @@ -1,4 +1,4 @@ -name: basic-auth-optional +name: basic-auth-pw-omitted auth: Basic auth-schemes: Basic: diff --git a/test-definitions/fern/apis/basic-auth-optional/definition/basic-auth.yml b/test-definitions/fern/apis/basic-auth-pw-omitted/definition/basic-auth.yml similarity index 100% rename from test-definitions/fern/apis/basic-auth-optional/definition/basic-auth.yml rename to test-definitions/fern/apis/basic-auth-pw-omitted/definition/basic-auth.yml diff --git a/test-definitions/fern/apis/basic-auth-optional/definition/errors.yml b/test-definitions/fern/apis/basic-auth-pw-omitted/definition/errors.yml similarity index 100% rename from test-definitions/fern/apis/basic-auth-optional/definition/errors.yml rename to test-definitions/fern/apis/basic-auth-pw-omitted/definition/errors.yml diff --git a/test-definitions/fern/apis/basic-auth-optional/generators.yml b/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml similarity index 86% rename from test-definitions/fern/apis/basic-auth-optional/generators.yml rename to test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml index b30d6ed97cd2..fc35e4407493 100644 --- a/test-definitions/fern/apis/basic-auth-optional/generators.yml +++ b/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml @@ -9,7 +9,7 @@ groups: token: ${GITHUB_TOKEN} mode: push uri: fern-api/php-sdk-tests - branch: basic-auth-optional + branch: basic-auth-pw-omitted go-sdk: generators: - name: fernapi/fern-go-sdk @@ -19,4 +19,4 @@ groups: token: ${GITHUB_TOKEN} mode: push uri: fern-api/go-sdk-tests - branch: basic-auth-optional + branch: basic-auth-pw-omitted From 64441f56fc3dddb82178b2ff2edefdfe0b0b7584 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 19:04:34 +0000 Subject: [PATCH 09/24] fix(php-sdk): bump version to 2.4.0 (feat requires minor bump) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- generators/php/sdk/versions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generators/php/sdk/versions.yml b/generators/php/sdk/versions.yml index b2abcf24f6f7..d1199c432a27 100644 --- a/generators/php/sdk/versions.yml +++ b/generators/php/sdk/versions.yml @@ -1,5 +1,5 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json -- version: 2.3.3 +- version: 2.4.0 changelogEntry: - summary: | Support omitting username or password from basic auth when configured via From eb74e5f6173e5f9e98e7fde544d0a154dcfee356 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:05:27 +0000 Subject: [PATCH 10/24] fix(php-sdk): bump seed fixture IR version to v63 so passwordOmit is visible to generator Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/Core/Json/JsonDecoder.php | 14 +++++++------- .../src/Core/Json/JsonDeserializer.php | 2 +- .../src/Core/Json/JsonSerializer.php | 2 +- .../basic-auth-pw-omitted/src/SeedClient.php | 4 +--- .../fern/apis/basic-auth-pw-omitted/generators.yml | 4 ++-- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php index 2ddff0273482..2da34087c644 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php @@ -20,7 +20,7 @@ public static function decodeString(string $json): string { $decoded = self::decode($json); if (!is_string($decoded)) { - throw new JsonException("Unexpected non-string json value: " . $json); + throw new JsonException("Unexpected non-string json value: $json"); } return $decoded; } @@ -36,7 +36,7 @@ public static function decodeBool(string $json): bool { $decoded = self::decode($json); if (!is_bool($decoded)) { - throw new JsonException("Unexpected non-boolean json value: " . $json); + throw new JsonException("Unexpected non-boolean json value: $json"); } return $decoded; } @@ -52,7 +52,7 @@ public static function decodeDateTime(string $json): DateTime { $decoded = self::decode($json); if (!is_string($decoded)) { - throw new JsonException("Unexpected non-string json value for datetime: " . $json); + throw new JsonException("Unexpected non-string json value for datetime: $json"); } return JsonDeserializer::deserializeDateTime($decoded); } @@ -68,7 +68,7 @@ public static function decodeDate(string $json): DateTime { $decoded = self::decode($json); if (!is_string($decoded)) { - throw new JsonException("Unexpected non-string json value for date: " . $json); + throw new JsonException("Unexpected non-string json value for date: $json"); } return JsonDeserializer::deserializeDate($decoded); } @@ -84,7 +84,7 @@ public static function decodeFloat(string $json): float { $decoded = self::decode($json); if (!is_float($decoded)) { - throw new JsonException("Unexpected non-float json value: " . $json); + throw new JsonException("Unexpected non-float json value: $json"); } return $decoded; } @@ -100,7 +100,7 @@ public static function decodeInt(string $json): int { $decoded = self::decode($json); if (!is_int($decoded)) { - throw new JsonException("Unexpected non-integer json value: " . $json); + throw new JsonException("Unexpected non-integer json value: $json"); } return $decoded; } @@ -117,7 +117,7 @@ public static function decodeArray(string $json, array $type): array { $decoded = self::decode($json); if (!is_array($decoded)) { - throw new JsonException("Unexpected non-array json value: " . $json); + throw new JsonException("Unexpected non-array json value: $json"); } return JsonDeserializer::deserializeArray($decoded, $type); } diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php index ca73bb2b970e..1a250c614e45 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php @@ -103,7 +103,7 @@ public static function deserializeUnion(mixed $data, Union $type): mixed } $readableType = Utils::getReadableType($data); throw new JsonException( - "Cannot deserialize value of type $readableType with any of the union types: " . $type + "Cannot deserialize value of type $readableType with any of the union types: $type" ); } diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php index 216de5aa4554..f7d80ed5e8f3 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php @@ -98,7 +98,7 @@ public static function serializeUnion(mixed $data, Union $unionType): mixed } $readableType = Utils::getReadableType($data); throw new JsonException( - "Cannot serialize value of type $readableType with any of the union types: " . $unionType + "Cannot serialize value of type $readableType with any of the union types: $unionType" ); } diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/SeedClient.php b/seed/php-sdk/basic-auth-pw-omitted/src/SeedClient.php index 197e6664136c..7908ddcdd780 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/SeedClient.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/SeedClient.php @@ -31,7 +31,6 @@ class SeedClient /** * @param string $username The username to use for authentication. - * @param string $password The password to use for authentication. * @param ?array{ * baseUrl?: string, * client?: ClientInterface, @@ -42,7 +41,6 @@ class SeedClient */ public function __construct( string $username, - string $password, ?array $options = null, ) { $defaultHeaders = [ @@ -51,7 +49,7 @@ public function __construct( 'X-Fern-SDK-Version' => '0.0.1', 'User-Agent' => 'seed/seed/0.0.1', ]; - $defaultHeaders['Authorization'] = "Basic " . base64_encode($username . ":" . $password); + $defaultHeaders['Authorization'] = "Basic " . base64_encode($username . ":" . ""); $this->options = $options ?? []; diff --git a/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml b/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml index fc35e4407493..50ee99b47bef 100644 --- a/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml +++ b/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml @@ -4,7 +4,7 @@ groups: generators: - name: fernapi/fern-php-sdk version: latest - ir-version: v61 + ir-version: v63 github: token: ${GITHUB_TOKEN} mode: push @@ -14,7 +14,7 @@ groups: generators: - name: fernapi/fern-go-sdk version: latest - ir-version: v61 + ir-version: v63 github: token: ${GITHUB_TOKEN} mode: push From daa1cd9d38f4338b55ed13cc827c06de0a61ccda Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:42:53 +0000 Subject: [PATCH 11/24] fix(php-sdk): handle usernameOmit/passwordOmit in dynamic snippets generator The dynamic snippets generator was still including omitted fields in constructor calls. Updated getConstructorBasicAuthArgs to check for usernameOmit/passwordOmit flags and skip omitted fields. Regenerated all 7 dynamic snippet files for basic-auth-pw-omitted. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/EndpointSnippetGenerator.ts | 20 +++++++++++++------ .../src/dynamic-snippets/example0/snippet.php | 1 - .../src/dynamic-snippets/example1/snippet.php | 1 - .../src/dynamic-snippets/example2/snippet.php | 1 - .../src/dynamic-snippets/example3/snippet.php | 1 - .../src/dynamic-snippets/example4/snippet.php | 1 - .../src/dynamic-snippets/example5/snippet.php | 1 - .../src/dynamic-snippets/example6/snippet.php | 1 - 8 files changed, 14 insertions(+), 13 deletions(-) diff --git a/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts index b713a66f6c9a..9c7da98fedb7 100644 --- a/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -321,16 +321,24 @@ export class EndpointSnippetGenerator { auth: FernIr.dynamic.BasicAuth; values: FernIr.dynamic.BasicAuthValues; }): NamedArgument[] { - return [ - { + // usernameOmit/passwordOmit may exist in newer IR versions + const authRecord = auth as unknown as Record; + const usernameOmitted = authRecord.usernameOmit === true; + const passwordOmitted = authRecord.passwordOmit === true; + const args: NamedArgument[] = []; + if (!usernameOmitted) { + args.push({ name: this.context.getPropertyName(auth.username), assignment: php.TypeLiteral.string(values.username) - }, - { + }); + } + if (!passwordOmitted) { + args.push({ name: this.context.getPropertyName(auth.password), assignment: php.TypeLiteral.string(values.password) - } - ]; + }); + } + return args; } private getConstructorEnvironmentArg({ diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php index f17c29ac0c45..70426907841c 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php @@ -6,7 +6,6 @@ $client = new SeedClient( username: '', - password: '', options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php index f17c29ac0c45..70426907841c 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php @@ -6,7 +6,6 @@ $client = new SeedClient( username: '', - password: '', options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php index f17c29ac0c45..70426907841c 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php @@ -6,7 +6,6 @@ $client = new SeedClient( username: '', - password: '', options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php index 379913d20cec..50c374428362 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php @@ -6,7 +6,6 @@ $client = new SeedClient( username: '', - password: '', options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php index 379913d20cec..50c374428362 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php @@ -6,7 +6,6 @@ $client = new SeedClient( username: '', - password: '', options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php index 379913d20cec..50c374428362 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php @@ -6,7 +6,6 @@ $client = new SeedClient( username: '', - password: '', options: [ 'baseUrl' => 'https://api.fern.com', ], diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php index 379913d20cec..50c374428362 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php +++ b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php @@ -6,7 +6,6 @@ $client = new SeedClient( username: '', - password: '', options: [ 'baseUrl' => 'https://api.fern.com', ], From 93578dd607459924f52114d1404bdf76f569a397 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:12:08 +0000 Subject: [PATCH 12/24] refactor(php-sdk): extract helper, simplify omit checks, remove ir-version constraint - Extract resolveBasicAuthScheme() helper to reduce inline verbosity - Replace isFirstBlock boolean with index-based control flow - Simplify === true to !! for omit checks - Remove ir-version: v63 from PHP seed config so passwordOmit survives in v66 IR Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/EndpointSnippetGenerator.ts | 4 +- .../src/root-client/RootClientGenerator.ts | 79 +++++++++++-------- .../apis/basic-auth-pw-omitted/generators.yml | 1 - 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts b/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts index 9c7da98fedb7..1a51101d7153 100644 --- a/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts +++ b/generators/php/dynamic-snippets/src/EndpointSnippetGenerator.ts @@ -323,8 +323,8 @@ export class EndpointSnippetGenerator { }): NamedArgument[] { // usernameOmit/passwordOmit may exist in newer IR versions const authRecord = auth as unknown as Record; - const usernameOmitted = authRecord.usernameOmit === true; - const passwordOmitted = authRecord.passwordOmit === true; + const usernameOmitted = !!authRecord.usernameOmit; + const passwordOmitted = !!authRecord.passwordOmit; const args: NamedArgument[] = []; if (!usernameOmitted) { args.push({ diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index a50e8e1e75e0..f483b647a96e 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -355,44 +355,21 @@ export class RootClientGenerator extends FileGenerator s.type === "basic" ); - if (basicAuthSchemes.length > 0) { + const resolvedBasicAuthSchemes = basicAuthSchemes + .map((scheme) => this.resolveBasicAuthScheme(scheme)) + .filter((resolved) => resolved != null); + if (resolvedBasicAuthSchemes.length > 0) { const isAuthOptional = !this.context.ir.sdkConfig.isAuthMandatory; - let isFirstBlock = true; - for (let i = 0; i < basicAuthSchemes.length; i++) { - const basicAuthScheme = basicAuthSchemes[i]; - if (basicAuthScheme == null) { - continue; + const needsControlFlow = isAuthOptional || resolvedBasicAuthSchemes.length > 1; + for (let i = 0; i < resolvedBasicAuthSchemes.length; i++) { + const { condition, usernameExpr, passwordExpr } = resolvedBasicAuthSchemes[i]!; + if (needsControlFlow) { + writer.controlFlow(i === 0 ? "if" : "else if", php.codeblock(condition)); } - const usernameName = this.context.getParameterName(basicAuthScheme.username); - const passwordName = this.context.getParameterName(basicAuthScheme.password); - // usernameOmit/passwordOmit may exist in newer IR versions - const scheme = basicAuthScheme as unknown as Record; - const usernameOmitted = scheme.usernameOmit === true; - const passwordOmitted = scheme.passwordOmit === true; - // Condition: only require non-omitted fields to be present - let condition: string; - if (!usernameOmitted && !passwordOmitted) { - condition = `$${usernameName} !== null && $${passwordName} !== null`; - } else if (usernameOmitted && !passwordOmitted) { - condition = `$${passwordName} !== null`; - } else if (!usernameOmitted && passwordOmitted) { - condition = `$${usernameName} !== null`; - } else { - // Both fields omitted — skip auth header entirely when auth is non-mandatory - continue; - } - if (isAuthOptional || basicAuthSchemes.length > 1) { - const controlFlowKeyword = isFirstBlock ? "if" : "else if"; - writer.controlFlow(controlFlowKeyword, php.codeblock(condition)); - } - isFirstBlock = false; - // Omitted fields use empty string directly - const usernameExpr = usernameOmitted ? `""` : `$${usernameName}`; - const passwordExpr = passwordOmitted ? `""` : `$${passwordName}`; writer.writeLine( `$defaultHeaders['Authorization'] = "Basic " . base64_encode(${usernameExpr} . ":" . ${passwordExpr});` ); - if (isAuthOptional || basicAuthSchemes.length > 1) { + if (needsControlFlow) { writer.endControlFlow(); } } @@ -625,8 +602,8 @@ export class RootClientGenerator extends FileGenerator; - const usernameOmitted = schemeRecord.usernameOmit === true; - const passwordOmitted = schemeRecord.passwordOmit === true; + const usernameOmitted = !!schemeRecord.usernameOmit; + const passwordOmitted = !!schemeRecord.passwordOmit; const params: ConstructorParameter[] = []; if (!usernameOmitted) { params.push({ @@ -781,6 +758,38 @@ export class RootClientGenerator extends FileGenerator; + const usernameOmitted = !!schemeRecord.usernameOmit; + const passwordOmitted = !!schemeRecord.passwordOmit; + + if (usernameOmitted && passwordOmitted) { + return undefined; + } + + const conditions: string[] = []; + if (!usernameOmitted) { + conditions.push(`$${usernameName} !== null`); + } + if (!passwordOmitted) { + conditions.push(`$${passwordName} !== null`); + } + + return { + condition: conditions.join(" && "), + usernameExpr: usernameOmitted ? `""` : `$${usernameName}`, + passwordExpr: passwordOmitted ? `""` : `$${passwordName}` + }; + } + private getRootSubpackages(): FernIr.Subpackage[] { return this.context.ir.rootPackage.subpackages .map((subpackageId) => { diff --git a/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml b/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml index 50ee99b47bef..64ff4bd50cfc 100644 --- a/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml +++ b/test-definitions/fern/apis/basic-auth-pw-omitted/generators.yml @@ -4,7 +4,6 @@ groups: generators: - name: fernapi/fern-php-sdk version: latest - ir-version: v63 github: token: ${GITHUB_TOKEN} mode: push From 6c2e94e18696800518def8dd174ac000a9fda47d Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:15:46 +0000 Subject: [PATCH 13/24] fix(php-sdk): update irVersion to 66 to match seed.yml Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- generators/php/sdk/versions.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/generators/php/sdk/versions.yml b/generators/php/sdk/versions.yml index d1199c432a27..b54d3ce2a567 100644 --- a/generators/php/sdk/versions.yml +++ b/generators/php/sdk/versions.yml @@ -9,7 +9,7 @@ both are omitted, the Authorization header is skipped entirely. type: feat createdAt: "2026-04-02" - irVersion: 62 + irVersion: 66 - version: 2.3.2-rc.0 changelogEntry: From 5f3442ff26bb59fff8350c850d41bb457d4e6f3d Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:23:20 +0000 Subject: [PATCH 14/24] fix(php-sdk): replace non-null assertion with null guard to satisfy biome lint Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- generators/php/sdk/src/root-client/RootClientGenerator.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index f483b647a96e..71e86e2c4b9c 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -362,7 +362,11 @@ export class RootClientGenerator extends FileGenerator 1; for (let i = 0; i < resolvedBasicAuthSchemes.length; i++) { - const { condition, usernameExpr, passwordExpr } = resolvedBasicAuthSchemes[i]!; + const resolved = resolvedBasicAuthSchemes[i]; + if (resolved == null) { + continue; + } + const { condition, usernameExpr, passwordExpr } = resolved; if (needsControlFlow) { writer.controlFlow(i === 0 ? "if" : "else if", php.codeblock(condition)); } From 76fa7d66eb4ea8c2be94f11db942e6b1d76a3912 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:31:14 +0000 Subject: [PATCH 15/24] refactor(php-sdk): remove unnecessary as unknown casts - IR SDK v66 has typed usernameOmit/passwordOmit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../php/sdk/src/root-client/RootClientGenerator.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index 71e86e2c4b9c..0d835960c30f 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -605,9 +605,8 @@ export class RootClientGenerator extends FileGenerator; - const usernameOmitted = !!schemeRecord.usernameOmit; - const passwordOmitted = !!schemeRecord.passwordOmit; + const usernameOmitted = !!scheme.usernameOmit; + const passwordOmitted = !!scheme.passwordOmit; const params: ConstructorParameter[] = []; if (!usernameOmitted) { params.push({ @@ -771,9 +770,8 @@ export class RootClientGenerator extends FileGenerator; - const usernameOmitted = !!schemeRecord.usernameOmit; - const passwordOmitted = !!schemeRecord.passwordOmit; + const usernameOmitted = !!scheme.usernameOmit; + const passwordOmitted = !!scheme.passwordOmit; if (usernameOmitted && passwordOmitted) { return undefined; From 637982c6b05179c2ea813e9ca2b8f18f0f555e07 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Fri, 3 Apr 2026 21:54:07 +0000 Subject: [PATCH 16/24] fix: pass usernameOmit/passwordOmit through DynamicSnippetsConverter to dynamic IR The DynamicSnippetsConverter was constructing dynamic BasicAuth with only username and password fields, dropping usernameOmit/passwordOmit from the main IR's BasicAuthScheme. This caused dynamic snippets generators to always include omitted auth fields (e.g. $password) since they couldn't detect the omit flags in the dynamic IR data. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../DynamicSnippetsConverter.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/cli/generation/ir-generator/src/dynamic-snippets/DynamicSnippetsConverter.ts b/packages/cli/generation/ir-generator/src/dynamic-snippets/DynamicSnippetsConverter.ts index b1750ea01c28..5675bc14999b 100644 --- a/packages/cli/generation/ir-generator/src/dynamic-snippets/DynamicSnippetsConverter.ts +++ b/packages/cli/generation/ir-generator/src/dynamic-snippets/DynamicSnippetsConverter.ts @@ -732,11 +732,22 @@ export class DynamicSnippetsConverter { } const scheme = auth.schemes[0]; switch (scheme.type) { - case "basic": - return DynamicSnippets.Auth.basic({ + case "basic": { + const basicAuth: DynamicSnippets.BasicAuth & { + usernameOmit?: boolean; + passwordOmit?: boolean; + } = { username: this.inflateName(scheme.username), password: this.inflateName(scheme.password) - }); + }; + if (scheme.usernameOmit) { + basicAuth.usernameOmit = scheme.usernameOmit; + } + if (scheme.passwordOmit) { + basicAuth.passwordOmit = scheme.passwordOmit; + } + return DynamicSnippets.Auth.basic(basicAuth); + } case "bearer": return DynamicSnippets.Auth.bearer({ token: this.inflateName(scheme.token) From 751d0c955fc116f91c18ba2e4b0421ec4447448c Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Sat, 4 Apr 2026 03:10:41 +0000 Subject: [PATCH 17/24] ci: retrigger CI (flaky python-sdk and test-ete failures) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From 7569356ee34ba565c00eda4fffeb9b827d00b631 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Sat, 4 Apr 2026 03:40:26 +0000 Subject: [PATCH 18/24] ci: retrigger CI (flaky python-sdk job failure) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From 913e829eca0afefcd19bea44b3243778c26ef926 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Sat, 4 Apr 2026 04:02:46 +0000 Subject: [PATCH 19/24] ci: retrigger CI (flaky test-ete timeout) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From 6ec9c1d216d630f7e2a2b4f3aa53fbe05c0fdf98 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:10:05 +0000 Subject: [PATCH 20/24] fix(php-sdk): clean credential string template and regenerate seed output - RootClientGenerator: build clean credential expression (e.g. $username . ":" instead of $username . ":" . "") - basic-auth-pw-omitted: SeedClient.php now has clean base64_encode($username . ":") - basic-auth: fix copy-paste bug in @param docs ("username" -> "password") - basic-auth: add WireMock Authorization header matching (from merged mock-utils changes) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/root-client/RootClientGenerator.ts | 19 ++++++++++++++----- .../basic-auth-pw-omitted/src/SeedClient.php | 2 +- .../basic-auth/wire-tests/src/SeedClient.php | 2 +- .../wiremock/wiremock-mappings.json | 14 ++++++++++++-- 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/generators/php/sdk/src/root-client/RootClientGenerator.ts b/generators/php/sdk/src/root-client/RootClientGenerator.ts index 0d835960c30f..b1f82e648fd9 100644 --- a/generators/php/sdk/src/root-client/RootClientGenerator.ts +++ b/generators/php/sdk/src/root-client/RootClientGenerator.ts @@ -366,12 +366,12 @@ export class RootClientGenerator extends FileGenerator '0.0.1', 'User-Agent' => 'seed/seed/0.0.1', ]; - $defaultHeaders['Authorization'] = "Basic " . base64_encode($username . ":" . ""); + $defaultHeaders['Authorization'] = "Basic " . base64_encode($username . ":"); $this->options = $options ?? []; diff --git a/seed/php-sdk/basic-auth/wire-tests/src/SeedClient.php b/seed/php-sdk/basic-auth/wire-tests/src/SeedClient.php index b94c5065f3ed..197e6664136c 100644 --- a/seed/php-sdk/basic-auth/wire-tests/src/SeedClient.php +++ b/seed/php-sdk/basic-auth/wire-tests/src/SeedClient.php @@ -31,7 +31,7 @@ class SeedClient /** * @param string $username The username to use for authentication. - * @param string $password The username to use for authentication. + * @param string $password The password to use for authentication. * @param ?array{ * baseUrl?: string, * client?: ClientInterface, diff --git a/seed/php-sdk/basic-auth/wire-tests/wiremock/wiremock-mappings.json b/seed/php-sdk/basic-auth/wire-tests/wiremock/wiremock-mappings.json index e4105fe17be0..043be0044335 100644 --- a/seed/php-sdk/basic-auth/wire-tests/wiremock/wiremock-mappings.json +++ b/seed/php-sdk/basic-auth/wire-tests/wiremock/wiremock-mappings.json @@ -5,7 +5,12 @@ "name": "getWithBasicAuth - default", "request": { "urlPathTemplate": "/basic-auth", - "method": "GET" + "method": "GET", + "headers": { + "Authorization": { + "equalTo": "Basic dGVzdC11c2VybmFtZTp0ZXN0LXBhc3N3b3Jk" + } + } }, "response": { "status": 200, @@ -32,7 +37,12 @@ "name": "postWithBasicAuth - default", "request": { "urlPathTemplate": "/basic-auth", - "method": "POST" + "method": "POST", + "headers": { + "Authorization": { + "equalTo": "Basic dGVzdC11c2VybmFtZTp0ZXN0LXBhc3N3b3Jk" + } + } }, "response": { "status": 200, From b2db526b2b6a63ca0d86e1dff4bcbb19cdec10d2 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:38:16 +0000 Subject: [PATCH 21/24] feat(php-sdk): enable wire tests for basic-auth-pw-omitted fixture - Add wire-tests output folder with enable-wire-tests: true in seed.yml - WireMock mappings assert Authorization: Basic dGVzdC11c2VybmFtZTo= (base64 of test-username:) - Generated SeedClient.php has only username param, no password - Wire test class validates basic auth header at request time via equalTo matcher Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../wire-tests/.fern/metadata.json | 10 + .../wire-tests/.github/workflows/ci.yml | 52 + .../wire-tests/.gitignore | 5 + .../wire-tests/README.md | 145 +++ .../wire-tests/composer.json | 46 + .../wire-tests/phpstan.neon | 6 + .../wire-tests/phpunit.xml | 7 + .../wire-tests/reference.md | 99 ++ .../wire-tests/snippet.json | 0 .../src/BasicAuth/BasicAuthClient.php | 146 +++ .../src/Core/Client/BaseApiRequest.php | 22 + .../src/Core/Client/HttpClientBuilder.php | 56 + .../wire-tests/src/Core/Client/HttpMethod.php | 12 + .../src/Core/Client/MockHttpClient.php | 75 ++ .../wire-tests/src/Core/Client/RawClient.php | 310 +++++ .../src/Core/Client/RetryDecoratingClient.php | 241 ++++ .../src/Core/Json/JsonApiRequest.php | 28 + .../wire-tests/src/Core/Json/JsonDecoder.php | 161 +++ .../src/Core/Json/JsonDeserializer.php | 218 ++++ .../wire-tests/src/Core/Json/JsonEncoder.php | 20 + .../wire-tests/src/Core/Json/JsonProperty.php | 13 + .../src/Core/Json/JsonSerializableType.php | 225 ++++ .../src/Core/Json/JsonSerializer.php | 205 ++++ .../wire-tests/src/Core/Json/Utils.php | 62 + .../Core/Multipart/MultipartApiRequest.php | 28 + .../src/Core/Multipart/MultipartFormData.php | 58 + .../Core/Multipart/MultipartFormDataPart.php | 62 + .../wire-tests/src/Core/Types/ArrayType.php | 16 + .../wire-tests/src/Core/Types/Constant.php | 12 + .../wire-tests/src/Core/Types/Date.php | 16 + .../wire-tests/src/Core/Types/Union.php | 62 + .../Types/UnauthorizedRequestErrorBody.php | 34 + .../src/Exceptions/SeedApiException.php | 53 + .../src/Exceptions/SeedException.php | 12 + .../wire-tests/src/SeedClient.php | 67 + .../wire-tests/src/Utils/File.php | 129 ++ .../src/dynamic-snippets/example0/snippet.php | 13 + .../src/dynamic-snippets/example1/snippet.php | 13 + .../src/dynamic-snippets/example2/snippet.php | 13 + .../src/dynamic-snippets/example3/snippet.php | 17 + .../src/dynamic-snippets/example4/snippet.php | 17 + .../src/dynamic-snippets/example5/snippet.php | 17 + .../src/dynamic-snippets/example6/snippet.php | 17 + .../tests/Core/Client/RawClientTest.php | 1074 +++++++++++++++++ .../Core/Json/AdditionalPropertiesTest.php | 76 ++ .../tests/Core/Json/DateArrayTest.php | 54 + .../tests/Core/Json/EmptyArrayTest.php | 71 ++ .../wire-tests/tests/Core/Json/EnumTest.php | 77 ++ .../tests/Core/Json/ExhaustiveTest.php | 197 +++ .../tests/Core/Json/InvalidTest.php | 42 + .../tests/Core/Json/NestedUnionArrayTest.php | 89 ++ .../tests/Core/Json/NullPropertyTest.php | 53 + .../tests/Core/Json/NullableArrayTest.php | 49 + .../wire-tests/tests/Core/Json/ScalarTest.php | 116 ++ .../wire-tests/tests/Core/Json/TraitTest.php | 60 + .../tests/Core/Json/UnionArrayTest.php | 57 + .../tests/Core/Json/UnionPropertyTest.php | 111 ++ .../tests/Wire/BasicAuthWireTest.php | 71 ++ .../tests/Wire/WireMockTestCase.php | 80 ++ .../wire-tests/tests/Wire/bootstrap.php | 55 + .../wiremock/docker-compose.test.yml | 14 + .../wiremock/wiremock-mappings.json | 70 ++ seed/php-sdk/seed.yml | 4 + 63 files changed, 5240 insertions(+) create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/.fern/metadata.json create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/.github/workflows/ci.yml create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/.gitignore create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/README.md create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/composer.json create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/phpstan.neon create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/phpunit.xml create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/reference.md create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/snippet.json create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/BasicAuth/BasicAuthClient.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/BaseApiRequest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/HttpClientBuilder.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/HttpMethod.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/MockHttpClient.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/RawClient.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/RetryDecoratingClient.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonApiRequest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonDecoder.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonDeserializer.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonEncoder.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonProperty.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonSerializableType.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonSerializer.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/Utils.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartApiRequest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartFormData.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartFormDataPart.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/ArrayType.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/Constant.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/Date.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/Union.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Errors/Types/UnauthorizedRequestErrorBody.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Exceptions/SeedApiException.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Exceptions/SeedException.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/SeedClient.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Utils/File.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example0/snippet.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example1/snippet.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example2/snippet.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example3/snippet.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example4/snippet.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example5/snippet.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example6/snippet.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Client/RawClientTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/AdditionalPropertiesTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/DateArrayTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/EmptyArrayTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/EnumTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/ExhaustiveTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/InvalidTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NestedUnionArrayTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NullPropertyTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NullableArrayTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/ScalarTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/TraitTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/UnionArrayTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/UnionPropertyTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/WireMockTestCase.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/bootstrap.php create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/wiremock/docker-compose.test.yml create mode 100644 seed/php-sdk/basic-auth-pw-omitted/wire-tests/wiremock/wiremock-mappings.json diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/.fern/metadata.json b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/.fern/metadata.json new file mode 100644 index 000000000000..d6b2eda38969 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/.fern/metadata.json @@ -0,0 +1,10 @@ +{ + "cliVersion": "DUMMY", + "generatorName": "fernapi/fern-php-sdk", + "generatorVersion": "latest", + "generatorConfig": { + "enable-wire-tests": true + }, + "originGitCommit": "DUMMY", + "sdkVersion": "0.0.1" +} \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/.github/workflows/ci.yml b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/.github/workflows/ci.yml new file mode 100644 index 000000000000..678eb6c9e141 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: ci + +on: [push] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + compile: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + + - name: Install tools + run: | + composer install + + - name: Build + run: | + composer build + + - name: Analyze + run: | + composer analyze + + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + + - name: Install tools + run: | + composer install + + - name: Run Tests + run: | + composer test diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/.gitignore b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/.gitignore new file mode 100644 index 000000000000..31a1aeb14f35 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/.gitignore @@ -0,0 +1,5 @@ +.idea +.php-cs-fixer.cache +.phpunit.result.cache +composer.lock +vendor/ \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/README.md b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/README.md new file mode 100644 index 000000000000..269cd0bb42b1 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/README.md @@ -0,0 +1,145 @@ +# Seed PHP Library + +[![fern shield](https://img.shields.io/badge/%F0%9F%8C%BF-Built%20with%20Fern-brightgreen)](https://buildwithfern.com?utm_source=github&utm_medium=github&utm_campaign=readme&utm_source=Seed%2FPHP) +[![php shield](https://img.shields.io/badge/php-packagist-pink)](https://packagist.org/packages/seed/seed) + +The Seed PHP library provides convenient access to the Seed APIs from PHP. + +## Table of Contents + +- [Requirements](#requirements) +- [Installation](#installation) +- [Usage](#usage) +- [Exception Handling](#exception-handling) +- [Advanced](#advanced) + - [Custom Client](#custom-client) + - [Retries](#retries) + - [Timeouts](#timeouts) +- [Contributing](#contributing) + +## Requirements + +This SDK requires PHP ^8.1. + +## Installation + +```sh +composer require seed/seed +``` + +## Usage + +Instantiate and use the client with the following: + +```php +', + password: '', +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); + +``` + +## Exception Handling + +When the API returns a non-success status code (4xx or 5xx response), an exception will be thrown. + +```php +use Seed\Exceptions\SeedApiException; +use Seed\Exceptions\SeedException; + +try { + $response = $client->basicAuth->postWithBasicAuth(...); +} catch (SeedApiException $e) { + echo 'API Exception occurred: ' . $e->getMessage() . "\n"; + echo 'Status Code: ' . $e->getCode() . "\n"; + echo 'Response Body: ' . $e->getBody() . "\n"; + // Optionally, rethrow the exception or handle accordingly. +} +``` + +## Advanced + +### Custom Client + +This SDK is built to work with any HTTP client that implements the [PSR-18](https://www.php-fig.org/psr/psr-18/) `ClientInterface`. +By default, if no client is provided, the SDK will use `php-http/discovery` to find an installed HTTP client. +However, you can pass your own client that adheres to `ClientInterface`: + +```php +use Seed\SeedClient; + +// Pass any PSR-18 compatible HTTP client implementation. +// For example, using Guzzle: +$customClient = new \GuzzleHttp\Client([ + 'timeout' => 5.0, +]); + +$client = new SeedClient(options: [ + 'client' => $customClient +]); + +// Or using Symfony HttpClient: +// $customClient = (new \Symfony\Component\HttpClient\Psr18Client()) +// ->withOptions(['timeout' => 5.0]); +// +// $client = new SeedClient(options: [ +// 'client' => $customClient +// ]); +``` + +### Retries + +The SDK is instrumented with automatic retries with exponential backoff. A request will be retried as long +as the request is deemed retryable and the number of retry attempts has not grown larger than the configured +retry limit (default: 2). + +A request is deemed retryable when any of the following HTTP status codes is returned: + +- [408](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/408) (Timeout) +- [429](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429) (Too Many Requests) +- [5XX](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500) (Internal Server Errors) + +Use the `maxRetries` request option to configure this behavior. + +```php +$response = $client->basicAuth->postWithBasicAuth( + ..., + options: [ + 'maxRetries' => 0 // Override maxRetries at the request level + ] +); +``` + +### Timeouts + +The SDK defaults to a 30 second timeout. Use the `timeout` option to configure this behavior. + +```php +$response = $client->basicAuth->postWithBasicAuth( + ..., + options: [ + 'timeout' => 3.0 // Override timeout at the request level + ] +); +``` + +## Contributing + +While we value open-source contributions to this SDK, this library is generated programmatically. +Additions made directly to this library would have to be moved over to our generation code, +otherwise they would be overwritten upon the next generated release. Feel free to open a PR as +a proof of concept, but know that we will not be able to merge it as-is. We suggest opening +an issue first to discuss with us! + +On the other hand, contributions to the README are always very welcome! diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/composer.json b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/composer.json new file mode 100644 index 000000000000..ad30960a8764 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/composer.json @@ -0,0 +1,46 @@ +{ + "name": "seed/seed", + "version": "0.0.1", + "description": "Seed PHP Library", + "keywords": [ + "seed", + "api", + "sdk" + ], + "license": [], + "require": { + "php": "^8.1", + "ext-json": "*", + "psr/http-client": "^1.0", + "psr/http-client-implementation": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-factory-implementation": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "php-http/discovery": "^1.0", + "php-http/multipart-stream-builder": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "friendsofphp/php-cs-fixer": "3.5.0", + "phpstan/phpstan": "^1.12", + "guzzlehttp/guzzle": "^7.4" + }, + "autoload": { + "psr-4": { + "Seed\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Seed\\Tests\\": "tests/" + } + }, + "scripts": { + "build": [ + "@php -l src", + "@php -l tests" + ], + "test": "phpunit", + "analyze": "phpstan analyze src tests --memory-limit=1G" + } +} \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/phpstan.neon b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/phpstan.neon new file mode 100644 index 000000000000..780706b8f8a2 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/phpstan.neon @@ -0,0 +1,6 @@ +parameters: + level: max + reportUnmatchedIgnoredErrors: false + paths: + - src + - tests \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/phpunit.xml b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/phpunit.xml new file mode 100644 index 000000000000..718cb50ee175 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/phpunit.xml @@ -0,0 +1,7 @@ + + + + tests + + + diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/reference.md b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/reference.md new file mode 100644 index 000000000000..76bf05ae1117 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/reference.md @@ -0,0 +1,99 @@ +# Reference +## BasicAuth +
$client->basicAuth->getWithBasicAuth() -> ?bool +
+
+ +#### 📝 Description + +
+
+ +
+
+ +GET request with basic auth scheme +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->basicAuth->getWithBasicAuth(); +``` +
+
+
+
+ + +
+
+
+ +
$client->basicAuth->postWithBasicAuth($request) -> ?bool +
+
+ +#### 📝 Description + +
+
+ +
+
+ +POST request with basic auth scheme +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$request:** `mixed` + +
+
+
+
+ + +
+
+
+ diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/snippet.json b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/snippet.json new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/BasicAuth/BasicAuthClient.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/BasicAuth/BasicAuthClient.php new file mode 100644 index 000000000000..ae6eddc5d2e0 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/BasicAuth/BasicAuthClient.php @@ -0,0 +1,146 @@ +, + * } $options @phpstan-ignore-next-line Property is used in endpoint methods via HttpEndpointGenerator + */ + private array $options; + + /** + * @var RawClient $client + */ + private RawClient $client; + + /** + * @param RawClient $client + * @param ?array{ + * baseUrl?: string, + * client?: ClientInterface, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * } $options + */ + public function __construct( + RawClient $client, + ?array $options = null, + ) { + $this->client = $client; + $this->options = $options ?? []; + } + + /** + * GET request with basic auth scheme + * + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?bool + * @throws SeedException + * @throws SeedApiException + */ + public function getWithBasicAuth(?array $options = null): ?bool + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', + path: "basic-auth", + method: HttpMethod::GET, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return JsonDecoder::decodeBool($json); + } + } catch (JsonException $e) { + throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + throw new SeedApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } + + /** + * POST request with basic auth scheme + * + * @param mixed $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?bool + * @throws SeedException + * @throws SeedApiException + */ + public function postWithBasicAuth(mixed $request, ?array $options = null): ?bool + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', + path: "basic-auth", + method: HttpMethod::POST, + body: $request, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return JsonDecoder::decodeBool($json); + } + } catch (JsonException $e) { + throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new SeedException(message: $e->getMessage(), previous: $e); + } + throw new SeedApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/BaseApiRequest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/BaseApiRequest.php new file mode 100644 index 000000000000..5e1283e2b6f6 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/BaseApiRequest.php @@ -0,0 +1,22 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + */ + public function __construct( + public readonly string $baseUrl, + public readonly string $path, + public readonly HttpMethod $method, + public readonly array $headers = [], + public readonly array $query = [], + ) { + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/HttpClientBuilder.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/HttpClientBuilder.php new file mode 100644 index 000000000000..8ac806af0325 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/HttpClientBuilder.php @@ -0,0 +1,56 @@ + + */ + private array $responses = []; + + /** + * @var array + */ + private array $requests = []; + + /** + * @param ResponseInterface ...$responses + */ + public function append(ResponseInterface ...$responses): void + { + foreach ($responses as $response) { + $this->responses[] = $response; + } + } + + /** + * @param RequestInterface $request + * @return ResponseInterface + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->requests[] = $request; + + if (empty($this->responses)) { + throw new RuntimeException('No more responses in the queue. Add responses using append().'); + } + + return array_shift($this->responses); + } + + /** + * @return ?RequestInterface + */ + public function getLastRequest(): ?RequestInterface + { + if (empty($this->requests)) { + return null; + } + return $this->requests[count($this->requests) - 1]; + } + + /** + * @return int + */ + public function getRequestCount(): int + { + return count($this->requests); + } + + /** + * Returns the number of remaining responses in the queue. + * + * @return int + */ + public function count(): int + { + return count($this->responses); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/RawClient.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/RawClient.php new file mode 100644 index 000000000000..14716c7d678b --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/RawClient.php @@ -0,0 +1,310 @@ + $headers + */ + private array $headers; + + /** + * @var ?(callable(): array) $getAuthHeaders + */ + private $getAuthHeaders; + + /** + * @param ?array{ + * baseUrl?: string, + * client?: ClientInterface, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * getAuthHeaders?: callable(): array, + * } $options + */ + public function __construct( + public readonly ?array $options = null, + ) { + $this->client = HttpClientBuilder::build( + $this->options['client'] ?? null, + $this->options['maxRetries'] ?? 2, + ); + $this->requestFactory = HttpClientBuilder::requestFactory(); + $this->streamFactory = HttpClientBuilder::streamFactory(); + $this->headers = $this->options['headers'] ?? []; + $this->getAuthHeaders = $this->options['getAuthHeaders'] ?? null; + } + + /** + * @param BaseApiRequest $request + * @param ?array{ + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ResponseInterface + * @throws ClientExceptionInterface + */ + public function sendRequest( + BaseApiRequest $request, + ?array $options = null, + ): ResponseInterface { + $opts = $options ?? []; + $httpRequest = $this->buildRequest($request, $opts); + + $timeout = $opts['timeout'] ?? $this->options['timeout'] ?? null; + $maxRetries = $opts['maxRetries'] ?? null; + + return $this->client->send($httpRequest, $timeout, $maxRetries); + } + + /** + * @param BaseApiRequest $request + * @param array{ + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return RequestInterface + */ + private function buildRequest( + BaseApiRequest $request, + array $options + ): RequestInterface { + $url = $this->buildUrl($request, $options); + $headers = $this->encodeHeaders($request, $options); + + $httpRequest = $this->requestFactory->createRequest( + $request->method->name, + $url, + ); + + // Encode body and, for multipart, capture the Content-Type with boundary. + if ($request instanceof MultipartApiRequest && $request->body !== null) { + $builder = new MultipartStreamBuilder($this->streamFactory); + $request->body->addToBuilder($builder); + $httpRequest = $httpRequest->withBody($builder->build()); + $headers['Content-Type'] = "multipart/form-data; boundary={$builder->getBoundary()}"; + } else { + $body = $this->encodeRequestBody($request, $options); + if ($body !== null) { + $httpRequest = $httpRequest->withBody($body); + } + } + + foreach ($headers as $name => $value) { + $httpRequest = $httpRequest->withHeader($name, $value); + } + + return $httpRequest; + } + + /** + * @param BaseApiRequest $request + * @param array{ + * headers?: array, + * } $options + * @return array + */ + private function encodeHeaders( + BaseApiRequest $request, + array $options, + ): array { + $authHeaders = $this->getAuthHeaders !== null ? ($this->getAuthHeaders)() : []; + return match (get_class($request)) { + JsonApiRequest::class => array_merge( + [ + "Content-Type" => "application/json", + "Accept" => "*/*", + ], + $this->headers, + $authHeaders, + $request->headers, + $options['headers'] ?? [], + ), + MultipartApiRequest::class => array_merge( + $this->headers, + $authHeaders, + $request->headers, + $options['headers'] ?? [], + ), + default => throw new InvalidArgumentException('Unsupported request type: ' . get_class($request)), + }; + } + + /** + * @param BaseApiRequest $request + * @param array{ + * bodyProperties?: array, + * } $options + * @return ?StreamInterface + */ + private function encodeRequestBody( + BaseApiRequest $request, + array $options, + ): ?StreamInterface { + if ($request instanceof JsonApiRequest) { + return $request->body === null ? null : $this->streamFactory->createStream( + JsonEncoder::encode( + $this->buildJsonBody( + $request->body, + $options, + ), + ) + ); + } + + if ($request instanceof MultipartApiRequest) { + return null; + } + + throw new InvalidArgumentException('Unsupported request type: ' . get_class($request)); + } + + /** + * @param mixed $body + * @param array{ + * bodyProperties?: array, + * } $options + * @return mixed + */ + private function buildJsonBody( + mixed $body, + array $options, + ): mixed { + $overrideProperties = $options['bodyProperties'] ?? []; + if (is_array($body) && (empty($body) || self::isSequential($body))) { + return array_merge($body, $overrideProperties); + } + + if ($body instanceof JsonSerializable) { + $result = $body->jsonSerialize(); + } else { + $result = $body; + } + if (is_array($result)) { + $result = array_merge($result, $overrideProperties); + if (empty($result)) { + // force to be serialized as {} instead of [] + return (object)($result); + } + } + + return $result; + } + + /** + * @param BaseApiRequest $request + * @param array{ + * queryParameters?: array, + * } $options + * @return string + */ + private function buildUrl( + BaseApiRequest $request, + array $options, + ): string { + $baseUrl = $request->baseUrl; + $trimmedBaseUrl = rtrim($baseUrl, '/'); + $trimmedBasePath = ltrim($request->path, '/'); + $url = "{$trimmedBaseUrl}/{$trimmedBasePath}"; + $query = array_merge( + $request->query, + $options['queryParameters'] ?? [], + ); + if (!empty($query)) { + $url .= '?' . $this->encodeQuery($query); + } + return $url; + } + + /** + * @param array $query + * @return string + */ + private function encodeQuery(array $query): string + { + $parts = []; + foreach ($query as $key => $value) { + if (is_array($value)) { + foreach ($value as $item) { + $parts[] = urlencode($key) . '=' . $this->encodeQueryValue($item); + } + } else { + $parts[] = urlencode($key) . '=' . $this->encodeQueryValue($value); + } + } + return implode('&', $parts); + } + + private function encodeQueryValue(mixed $value): string + { + if (is_string($value)) { + return urlencode($value); + } + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_scalar($value)) { + return urlencode((string)$value); + } + if (is_null($value)) { + return 'null'; + } + // Unreachable, but included for a best effort. + return urlencode(JsonEncoder::encode($value)); + } + + /** + * Check if an array is sequential, not associative. + * @param mixed[] $arr + * @return bool + */ + private static function isSequential(array $arr): bool + { + if (empty($arr)) { + return false; + } + $length = count($arr); + $keys = array_keys($arr); + for ($i = 0; $i < $length; $i++) { + if ($keys[$i] !== $i) { + return false; + } + } + return true; + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/RetryDecoratingClient.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/RetryDecoratingClient.php new file mode 100644 index 000000000000..b16170cf2805 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Client/RetryDecoratingClient.php @@ -0,0 +1,241 @@ +client = $client; + $this->maxRetries = $maxRetries; + $this->baseDelay = $baseDelay; + $this->sleepFunction = $sleepFunction ?? 'usleep'; + } + + /** + * @param RequestInterface $request + * @return ResponseInterface + * @throws ClientExceptionInterface + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->send($request); + } + + /** + * Sends a request with optional per-request timeout and retry overrides. + * + * When a Guzzle or Symfony PSR-18 client is detected, the timeout is + * forwarded via the client's native API. For other PSR-18 clients the + * timeout value is silently ignored. + * + * @param RequestInterface $request + * @param ?float $timeout Timeout in seconds, or null to use the client default. + * @param ?int $maxRetries Maximum retry attempts, or null to use the client default. + * @return ResponseInterface + * @throws ClientExceptionInterface + */ + public function send( + RequestInterface $request, + ?float $timeout = null, + ?int $maxRetries = null, + ): ResponseInterface { + $maxRetries = $maxRetries ?? $this->maxRetries; + $retryAttempt = 0; + $lastResponse = null; + + while (true) { + try { + $lastResponse = $this->doSend($request, $timeout); + if (!$this->shouldRetry($retryAttempt, $maxRetries, $lastResponse)) { + return $lastResponse; + } + } catch (ClientExceptionInterface $e) { + if ($retryAttempt >= $maxRetries) { + throw $e; + } + } + + $retryAttempt++; + $delay = $this->getRetryDelay($retryAttempt, $lastResponse); + ($this->sleepFunction)($delay * 1000); // Convert milliseconds to microseconds + + // Rewind the request body so retries don't send an empty body. + $request->getBody()->rewind(); + } + } + + /** + * Dispatches the request to the underlying client, forwarding the timeout + * option to Guzzle or Symfony when available. + * + * @param RequestInterface $request + * @param ?float $timeout + * @return ResponseInterface + * @throws ClientExceptionInterface + */ + private function doSend(RequestInterface $request, ?float $timeout): ResponseInterface + { + static $warned = false; + + if ($timeout === null) { + return $this->client->sendRequest($request); + } + + if (class_exists('GuzzleHttp\ClientInterface') + && $this->client instanceof \GuzzleHttp\ClientInterface + ) { + return $this->client->send($request, ['timeout' => $timeout]); + } + if (class_exists('Symfony\Component\HttpClient\Psr18Client') + && $this->client instanceof \Symfony\Component\HttpClient\Psr18Client + ) { + /** @var ClientInterface $clientWithTimeout */ + $clientWithTimeout = $this->client->withOptions(['timeout' => $timeout]); + return $clientWithTimeout->sendRequest($request); + } + + if ($warned) { + return $this->client->sendRequest($request); + } + $warned = true; + trigger_error( + 'Timeout option is not supported for the current PSR-18 client (' + . get_class($this->client) + . '). Use Guzzle or Symfony HttpClient for timeout support.', + E_USER_WARNING, + ); + return $this->client->sendRequest($request); + } + + /** + * @param int $retryAttempt + * @param int $maxRetries + * @param ?ResponseInterface $response + * @return bool + */ + private function shouldRetry( + int $retryAttempt, + int $maxRetries, + ?ResponseInterface $response = null, + ): bool { + if ($retryAttempt >= $maxRetries) { + return false; + } + + if ($response !== null) { + return $response->getStatusCode() >= 500 || + in_array($response->getStatusCode(), self::RETRY_STATUS_CODES); + } + + return false; + } + + /** + * Calculate the retry delay based on response headers or exponential backoff. + * + * @param int $retryAttempt + * @param ?ResponseInterface $response + * @return int milliseconds + */ + private function getRetryDelay(int $retryAttempt, ?ResponseInterface $response): int + { + if ($response !== null) { + // Check Retry-After header + $retryAfter = $response->getHeaderLine('Retry-After'); + if ($retryAfter !== '') { + // Try parsing as integer (seconds) + if (is_numeric($retryAfter)) { + $retryAfterSeconds = (int)$retryAfter; + if ($retryAfterSeconds > 0) { + return min($retryAfterSeconds * 1000, self::MAX_RETRY_DELAY); + } + } + + // Try parsing as HTTP date + $retryAfterDate = strtotime($retryAfter); + if ($retryAfterDate !== false) { + $delay = ($retryAfterDate - time()) * 1000; + if ($delay > 0) { + return min(max($delay, 0), self::MAX_RETRY_DELAY); + } + } + } + + // Check X-RateLimit-Reset header + $rateLimitReset = $response->getHeaderLine('X-RateLimit-Reset'); + if ($rateLimitReset !== '' && is_numeric($rateLimitReset)) { + $resetTime = (int)$rateLimitReset; + $delay = ($resetTime * 1000) - (int)(microtime(true) * 1000); + if ($delay > 0) { + return $this->addPositiveJitter(min($delay, self::MAX_RETRY_DELAY)); + } + } + } + + // Fall back to exponential backoff with symmetric jitter + return $this->addSymmetricJitter( + min($this->exponentialDelay($retryAttempt), self::MAX_RETRY_DELAY) + ); + } + + /** + * Add positive jitter (0% to +20%) to the delay. + * + * @param int $delay + * @return int + */ + private function addPositiveJitter(int $delay): int + { + $jitterMultiplier = 1 + (mt_rand() / mt_getrandmax()) * self::JITTER_FACTOR; + return (int)($delay * $jitterMultiplier); + } + + /** + * Add symmetric jitter (-10% to +10%) to the delay. + * + * @param int $delay + * @return int + */ + private function addSymmetricJitter(int $delay): int + { + $jitterMultiplier = 1 + ((mt_rand() / mt_getrandmax()) - 0.5) * self::JITTER_FACTOR; + return (int)($delay * $jitterMultiplier); + } + + /** + * Default exponential backoff delay function. + * + * @return int milliseconds. + */ + private function exponentialDelay(int $retryAttempt): int + { + return 2 ** ($retryAttempt - 1) * $this->baseDelay; + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonApiRequest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonApiRequest.php new file mode 100644 index 000000000000..8fdf493606e6 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonApiRequest.php @@ -0,0 +1,28 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + * @param mixed|null $body The JSON request body (optional) + */ + public function __construct( + string $baseUrl, + string $path, + HttpMethod $method, + array $headers = [], + array $query = [], + public readonly mixed $body = null + ) { + parent::__construct($baseUrl, $path, $method, $headers, $query); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonDecoder.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonDecoder.php new file mode 100644 index 000000000000..2da34087c644 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonDecoder.php @@ -0,0 +1,161 @@ + $type The type definition for deserialization. + * @return mixed[]|array The deserialized array. + * @throws JsonException If the decoded value is not an array. + */ + public static function decodeArray(string $json, array $type): array + { + $decoded = self::decode($json); + if (!is_array($decoded)) { + throw new JsonException("Unexpected non-array json value: $json"); + } + return JsonDeserializer::deserializeArray($decoded, $type); + } + + /** + * Decodes a JSON string and deserializes it based on the provided union type definition. + * + * @param string $json The JSON string to decode. + * @param Union $union The union type definition for deserialization. + * @return mixed The deserialized value. + * @throws JsonException If the deserialization for all types in the union fails. + */ + public static function decodeUnion(string $json, Union $union): mixed + { + $decoded = self::decode($json); + return JsonDeserializer::deserializeUnion($decoded, $union); + } + /** + * Decodes a JSON string and returns a mixed. + * + * @param string $json The JSON string to decode. + * @return mixed The decoded mixed. + * @throws JsonException If the decoded value is not an mixed. + */ + public static function decodeMixed(string $json): mixed + { + return self::decode($json); + } + + /** + * Decodes a JSON string into a PHP value. + * + * @param string $json The JSON string to decode. + * @return mixed The decoded value. + * @throws JsonException If an error occurs during JSON decoding. + */ + public static function decode(string $json): mixed + { + return json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonDeserializer.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonDeserializer.php new file mode 100644 index 000000000000..1a250c614e45 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonDeserializer.php @@ -0,0 +1,218 @@ + $data The array to be deserialized. + * @param array $type The type definition from the annotation. + * @return array The deserialized array. + * @throws JsonException If deserialization fails. + */ + public static function deserializeArray(array $data, array $type): array + { + return Utils::isMapType($type) + ? self::deserializeMap($data, $type) + : self::deserializeList($data, $type); + } + + /** + * Deserializes a value based on its type definition. + * + * @param mixed $data The data to deserialize. + * @param mixed $type The type definition. + * @return mixed The deserialized value. + * @throws JsonException If deserialization fails. + */ + private static function deserializeValue(mixed $data, mixed $type): mixed + { + if ($type instanceof Union) { + return self::deserializeUnion($data, $type); + } + + if (is_array($type)) { + return self::deserializeArray((array)$data, $type); + } + + if (gettype($type) !== "string") { + throw new JsonException("Unexpected non-string type."); + } + + return self::deserializeSingleValue($data, $type); + } + + /** + * Deserializes a value based on the possible types in a union type definition. + * + * @param mixed $data The data to deserialize. + * @param Union $type The union type definition. + * @return mixed The deserialized value. + * @throws JsonException If none of the union types can successfully deserialize the value. + */ + public static function deserializeUnion(mixed $data, Union $type): mixed + { + foreach ($type->types as $unionType) { + try { + return self::deserializeValue($data, $unionType); + } catch (\Throwable) { + // Catching Throwable instead of Exception to handle TypeError + // that occurs when assigning null to non-nullable typed properties + continue; + } + } + $readableType = Utils::getReadableType($data); + throw new JsonException( + "Cannot deserialize value of type $readableType with any of the union types: $type" + ); + } + + /** + * Deserializes a single value based on its expected type. + * + * @param mixed $data The data to deserialize. + * @param string $type The expected type. + * @return mixed The deserialized value. + * @throws JsonException If deserialization fails. + */ + private static function deserializeSingleValue(mixed $data, string $type): mixed + { + if ($type === 'null' && $data === null) { + return null; + } + + if ($type === 'date' && is_string($data)) { + return self::deserializeDate($data); + } + + if ($type === 'datetime' && is_string($data)) { + return self::deserializeDateTime($data); + } + + if ($type === 'mixed') { + return $data; + } + + if (class_exists($type) && is_array($data)) { + /** @var array $data */ + return self::deserializeObject($data, $type); + } + + // Handle floats as a special case since gettype($data) returns "double" for float values in PHP, and because + // floats make come through from json_decoded as integers + if ($type === 'float' && (is_numeric($data))) { + return (float) $data; + } + + // Handle bools as a special case since gettype($data) returns "boolean" for bool values in PHP. + if ($type === 'bool' && is_bool($data)) { + return $data; + } + + if (gettype($data) === $type) { + return $data; + } + + throw new JsonException("Unable to deserialize value of type '" . gettype($data) . "' as '$type'."); + } + + /** + * Deserializes an array into an object of the given type. + * + * @param array $data The data to deserialize. + * @param string $type The class name of the object to deserialize into. + * + * @return object The deserialized object. + * + * @throws JsonException If the type does not implement JsonSerializableType. + */ + public static function deserializeObject(array $data, string $type): object + { + if (!is_subclass_of($type, JsonSerializableType::class)) { + throw new JsonException("$type is not a subclass of JsonSerializableType."); + } + return $type::jsonDeserialize($data); + } + + /** + * Deserializes a map (associative array) with defined key and value types. + * + * @param array $data The associative array to deserialize. + * @param array $type The type definition for the map. + * @return array The deserialized map. + * @throws JsonException If deserialization fails. + */ + private static function deserializeMap(array $data, array $type): array + { + $keyType = array_key_first($type); + if ($keyType === null) { + throw new JsonException("Unexpected no key in ArrayType."); + } + $keyType = (string) $keyType; + $valueType = $type[$keyType]; + /** @var array $result */ + $result = []; + + foreach ($data as $key => $item) { + $key = (string) Utils::castKey($key, $keyType); + $result[$key] = self::deserializeValue($item, $valueType); + } + + return $result; + } + + /** + * Deserializes a list (indexed array) with a defined value type. + * + * @param array $data The list to deserialize. + * @param array $type The type definition for the list. + * @return array The deserialized list. + * @throws JsonException If deserialization fails. + */ + private static function deserializeList(array $data, array $type): array + { + $valueType = $type[0]; + /** @var array */ + return array_map(fn ($item) => self::deserializeValue($item, $valueType), $data); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonEncoder.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonEncoder.php new file mode 100644 index 000000000000..0dbf3fcc9948 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonEncoder.php @@ -0,0 +1,20 @@ + Extra properties from JSON that don't map to class properties */ + private array $__additionalProperties = []; + + /** @var array Properties that have been explicitly set via setter methods */ + private array $__explicitlySetProperties = []; + + /** + * Serializes the object to a JSON string. + * + * @return string JSON-encoded string representation of the object. + * @throws Exception If encoding fails. + */ + public function toJson(): string + { + $serializedObject = $this->jsonSerialize(); + $encoded = JsonEncoder::encode($serializedObject); + if (!$encoded) { + throw new Exception("Could not encode type"); + } + return $encoded; + } + + /** + * Serializes the object to an array. + * + * @return mixed[] Array representation of the object. + * @throws JsonException If serialization fails. + */ + public function jsonSerialize(): array + { + $result = []; + $reflectionClass = new \ReflectionClass($this); + foreach ($reflectionClass->getProperties() as $property) { + $jsonKey = self::getJsonKey($property); + if ($jsonKey === null) { + continue; + } + $value = $property->getValue($this); + + // Handle DateTime properties + $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; + if ($dateTypeAttr && $value instanceof DateTime) { + $dateType = $dateTypeAttr->newInstance()->type; + $value = ($dateType === Date::TYPE_DATE) + ? JsonSerializer::serializeDate($value) + : JsonSerializer::serializeDateTime($value); + } + + // Handle Union annotations + $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; + if ($unionTypeAttr) { + $unionType = $unionTypeAttr->newInstance(); + $value = JsonSerializer::serializeUnion($value, $unionType); + } + + // Handle arrays with type annotations + $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; + if ($arrayTypeAttr && is_array($value)) { + $arrayType = $arrayTypeAttr->newInstance()->type; + $value = JsonSerializer::serializeArray($value, $arrayType); + } + + // Handle object + if (is_object($value)) { + $value = JsonSerializer::serializeObject($value); + } + + // Include the value if it's not null, OR if it was explicitly set (even to null) + if ($value !== null || array_key_exists($property->getName(), $this->__explicitlySetProperties)) { + $result[$jsonKey] = $value; + } + } + return $result; + } + + /** + * Deserializes a JSON string into an instance of the calling class. + * + * @param string $json JSON string to deserialize. + * @return static Deserialized object. + * @throws JsonException If decoding fails or the result is not an array. + * @throws Exception If deserialization fails. + */ + public static function fromJson(string $json): static + { + $decodedJson = JsonDecoder::decode($json); + if (!is_array($decodedJson)) { + throw new JsonException("Unexpected non-array decoded type: " . gettype($decodedJson)); + } + /** @var array $decodedJson */ + return self::jsonDeserialize($decodedJson); + } + + /** + * Deserializes an array into an instance of the calling class. + * + * @param array $data Array data to deserialize. + * @return static Deserialized object. + * @throws JsonException If deserialization fails. + */ + public static function jsonDeserialize(array $data): static + { + $reflectionClass = new \ReflectionClass(static::class); + $constructor = $reflectionClass->getConstructor(); + if ($constructor === null) { + throw new JsonException("No constructor found."); + } + + $args = []; + $properties = []; + $additionalProperties = []; + foreach ($reflectionClass->getProperties() as $property) { + $jsonKey = self::getJsonKey($property) ?? $property->getName(); + $properties[$jsonKey] = $property; + } + + foreach ($data as $jsonKey => $value) { + if (!isset($properties[$jsonKey])) { + // This JSON key doesn't map to any class property - add it to additionalProperties + $additionalProperties[$jsonKey] = $value; + continue; + } + + $property = $properties[$jsonKey]; + + // Handle Date annotation + $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; + if ($dateTypeAttr) { + $dateType = $dateTypeAttr->newInstance()->type; + if (!is_string($value)) { + throw new JsonException("Unexpected non-string type for date."); + } + $value = ($dateType === Date::TYPE_DATE) + ? JsonDeserializer::deserializeDate($value) + : JsonDeserializer::deserializeDateTime($value); + } + + // Handle Array annotation + $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; + if (is_array($value) && $arrayTypeAttr) { + $arrayType = $arrayTypeAttr->newInstance()->type; + $value = JsonDeserializer::deserializeArray($value, $arrayType); + } + + // Handle Union annotations + $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; + if ($unionTypeAttr) { + $unionType = $unionTypeAttr->newInstance(); + $value = JsonDeserializer::deserializeUnion($value, $unionType); + } + + // Handle object + $type = $property->getType(); + if (is_array($value) && $type instanceof ReflectionNamedType && !$type->isBuiltin()) { + /** @var array $arrayValue */ + $arrayValue = $value; + $value = JsonDeserializer::deserializeObject($arrayValue, $type->getName()); + } + + $args[$property->getName()] = $value; + } + + // Fill in any missing properties with defaults + foreach ($properties as $property) { + if (!isset($args[$property->getName()])) { + $args[$property->getName()] = $property->hasDefaultValue() ? $property->getDefaultValue() : null; + } + } + + // @phpstan-ignore-next-line + $result = new static($args); + $result->__additionalProperties = $additionalProperties; + return $result; + } + + /** + * Get properties from JSON that weren't mapped to class fields + * @return array + */ + public function getAdditionalProperties(): array + { + return $this->__additionalProperties; + } + + /** + * Mark a property as explicitly set. + * This ensures the property will be included in JSON serialization even if null. + * + * @param string $propertyName The name of the property to mark as explicitly set. + */ + protected function _setField(string $propertyName): void + { + $this->__explicitlySetProperties[$propertyName] = true; + } + + /** + * Retrieves the JSON key associated with a property. + * + * @param ReflectionProperty $property The reflection property. + * @return ?string The JSON key, or null if not available. + */ + private static function getJsonKey(ReflectionProperty $property): ?string + { + $jsonPropertyAttr = $property->getAttributes(JsonProperty::class)[0] ?? null; + return $jsonPropertyAttr?->newInstance()?->name; + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonSerializer.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonSerializer.php new file mode 100644 index 000000000000..f7d80ed5e8f3 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/JsonSerializer.php @@ -0,0 +1,205 @@ +format(Constant::DateFormat); + } + + /** + * Serializes a DateTime object into a string using the date-time format. + * Normalizes UTC times to use 'Z' suffix instead of '+00:00'. + * + * @param DateTime $date The DateTime object to serialize. + * @return string The serialized date-time string. + */ + public static function serializeDateTime(DateTime $date): string + { + $formatted = $date->format(Constant::DateTimeFormat); + if (str_ends_with($formatted, '+00:00')) { + return substr($formatted, 0, -6) . 'Z'; + } + return $formatted; + } + + /** + * Serializes an array based on type annotations (either a list or map). + * + * @param array $data The array to be serialized. + * @param array $type The type definition from the annotation. + * @return array The serialized array. + * @throws JsonException If serialization fails. + */ + public static function serializeArray(array $data, array $type): array + { + return Utils::isMapType($type) + ? self::serializeMap($data, $type) + : self::serializeList($data, $type); + } + + /** + * Serializes a value based on its type definition. + * + * @param mixed $data The value to serialize. + * @param mixed $type The type definition. + * @return mixed The serialized value. + * @throws JsonException If serialization fails. + */ + private static function serializeValue(mixed $data, mixed $type): mixed + { + if ($type instanceof Union) { + return self::serializeUnion($data, $type); + } + + if (is_array($type)) { + return self::serializeArray((array)$data, $type); + } + + if (gettype($type) !== "string") { + throw new JsonException("Unexpected non-string type."); + } + + return self::serializeSingleValue($data, $type); + } + + /** + * Serializes a value for a union type definition. + * + * @param mixed $data The value to serialize. + * @param Union $unionType The union type definition. + * @return mixed The serialized value. + * @throws JsonException If serialization fails for all union types. + */ + public static function serializeUnion(mixed $data, Union $unionType): mixed + { + foreach ($unionType->types as $type) { + try { + return self::serializeValue($data, $type); + } catch (Exception) { + // Try the next type in the union + continue; + } + } + $readableType = Utils::getReadableType($data); + throw new JsonException( + "Cannot serialize value of type $readableType with any of the union types: $unionType" + ); + } + + /** + * Serializes a single value based on its type. + * + * @param mixed $data The value to serialize. + * @param string $type The expected type. + * @return mixed The serialized value. + * @throws JsonException If serialization fails. + */ + private static function serializeSingleValue(mixed $data, string $type): mixed + { + if ($type === 'null' && $data === null) { + return null; + } + + if (($type === 'date' || $type === 'datetime') && $data instanceof DateTime) { + return $type === 'date' ? self::serializeDate($data) : self::serializeDateTime($data); + } + + if ($type === 'mixed') { + return $data; + } + + if (class_exists($type) && $data instanceof $type) { + return self::serializeObject($data); + } + + // Handle floats as a special case since gettype($data) returns "double" for float values in PHP. + if ($type === 'float' && is_float($data)) { + return $data; + } + + // Handle bools as a special case since gettype($data) returns "boolean" for bool values in PHP. + if ($type === 'bool' && is_bool($data)) { + return $data; + } + + if (gettype($data) === $type) { + return $data; + } + + throw new JsonException("Unable to serialize value of type '" . gettype($data) . "' as '$type'."); + } + + /** + * Serializes an object to a JSON-serializable format. + * + * @param object $data The object to serialize. + * @return mixed The serialized data. + * @throws JsonException If the object does not implement JsonSerializable. + */ + public static function serializeObject(object $data): mixed + { + if (!is_subclass_of($data, JsonSerializable::class)) { + $type = get_class($data); + throw new JsonException("Class $type must implement JsonSerializable."); + } + return $data->jsonSerialize(); + } + + /** + * Serializes a map (associative array) with defined key and value types. + * + * @param array $data The associative array to serialize. + * @param array $type The type definition for the map. + * @return array The serialized map. + * @throws JsonException If serialization fails. + */ + private static function serializeMap(array $data, array $type): array + { + $keyType = array_key_first($type); + if ($keyType === null) { + throw new JsonException("Unexpected no key in ArrayType."); + } + $keyType = (string) $keyType; + $valueType = $type[$keyType]; + /** @var array $result */ + $result = []; + + foreach ($data as $key => $item) { + $key = (string) Utils::castKey($key, $keyType); + $result[$key] = self::serializeValue($item, $valueType); + } + + return $result; + } + + /** + * Serializes a list (indexed array) where only the value type is defined. + * + * @param array $data The list to serialize. + * @param array $type The type definition for the list. + * @return array The serialized list. + * @throws JsonException If serialization fails. + */ + private static function serializeList(array $data, array $type): array + { + $valueType = $type[0]; + /** @var array */ + return array_map(fn ($item) => self::serializeValue($item, $valueType), $data); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/Utils.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/Utils.php new file mode 100644 index 000000000000..4099b8253005 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Json/Utils.php @@ -0,0 +1,62 @@ + $type The type definition from the annotation. + * @return bool True if the type is a map, false if it's a list. + */ + public static function isMapType(array $type): bool + { + return count($type) === 1 && !array_is_list($type); + } + + /** + * Casts the key to the appropriate type based on the key type. + * + * @param mixed $key The key to be cast. + * @param string $keyType The type to cast the key to ('string', 'integer', 'float'). + * @return int|string The casted key. + * @throws JsonException + */ + public static function castKey(mixed $key, string $keyType): int|string + { + if (!is_scalar($key)) { + throw new JsonException("Key must be a scalar type."); + } + return match ($keyType) { + 'integer' => (int)$key, + // PHP arrays don't support float keys; truncate to int + 'float' => (int)$key, + 'string' => (string)$key, + default => is_int($key) ? $key : (string)$key, + }; + } + + /** + * Returns a human-readable representation of the input's type. + * + * @param mixed $input The input value to determine the type of. + * @return string A readable description of the input type. + */ + public static function getReadableType(mixed $input): string + { + if (is_object($input)) { + return get_class($input); + } elseif (is_array($input)) { + return 'array(' . count($input) . ' items)'; + } elseif (is_null($input)) { + return 'null'; + } else { + return gettype($input); + } + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartApiRequest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartApiRequest.php new file mode 100644 index 000000000000..7760366456c8 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartApiRequest.php @@ -0,0 +1,28 @@ + $headers Additional headers for the request (optional) + * @param array $query Query parameters for the request (optional) + * @param ?MultipartFormData $body The multipart form data for the request (optional) + */ + public function __construct( + string $baseUrl, + string $path, + HttpMethod $method, + array $headers = [], + array $query = [], + public readonly ?MultipartFormData $body = null + ) { + parent::__construct($baseUrl, $path, $method, $headers, $query); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartFormData.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartFormData.php new file mode 100644 index 000000000000..911a28b6ad64 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartFormData.php @@ -0,0 +1,58 @@ + + */ + private array $parts = []; + + /** + * Adds a new part to the multipart form data. + * + * @param string $name + * @param string|int|bool|float|StreamInterface $value + * @param ?string $contentType + */ + public function add( + string $name, + string|int|bool|float|StreamInterface $value, + ?string $contentType = null, + ): void { + $headers = $contentType !== null ? ['Content-Type' => $contentType] : null; + $this->addPart( + new MultipartFormDataPart( + name: $name, + value: $value, + headers: $headers, + ) + ); + } + + /** + * Adds a new part to the multipart form data. + * + * @param MultipartFormDataPart $part + */ + public function addPart(MultipartFormDataPart $part): void + { + $this->parts[] = $part; + } + + /** + * Adds all parts to a MultipartStreamBuilder. + * + * @param MultipartStreamBuilder $builder + */ + public function addToBuilder(MultipartStreamBuilder $builder): void + { + foreach ($this->parts as $part) { + $part->addToBuilder($builder); + } + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartFormDataPart.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartFormDataPart.php new file mode 100644 index 000000000000..4db35e58ae37 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Multipart/MultipartFormDataPart.php @@ -0,0 +1,62 @@ + + */ + private ?array $headers; + + /** + * @param string $name + * @param string|bool|float|int|StreamInterface $value + * @param ?string $filename + * @param ?array $headers + */ + public function __construct( + string $name, + string|bool|float|int|StreamInterface $value, + ?string $filename = null, + ?array $headers = null + ) { + $this->name = $name; + $this->contents = $value instanceof StreamInterface ? $value : (string)$value; + $this->filename = $filename; + $this->headers = $headers; + } + + /** + * Adds this part to a MultipartStreamBuilder. + * + * @param MultipartStreamBuilder $builder + */ + public function addToBuilder(MultipartStreamBuilder $builder): void + { + $options = array_filter([ + 'filename' => $this->filename, + 'headers' => $this->headers, + ], fn ($value) => $value !== null); + + $builder->addResource($this->name, $this->contents, $options); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/ArrayType.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/ArrayType.php new file mode 100644 index 000000000000..a26d29008ec3 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/ArrayType.php @@ -0,0 +1,16 @@ + 'valueType'] for maps, or ['valueType'] for lists + */ + public function __construct(public array $type) + { + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/Constant.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/Constant.php new file mode 100644 index 000000000000..5ac4518cc6d6 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Core/Types/Constant.php @@ -0,0 +1,12 @@ +> The types allowed for this property, which can be strings, arrays, or nested Union types. + */ + public array $types; + + /** + * Constructor for the Union attribute. + * + * @param string|Union|array ...$types The list of types that the property can accept. + * This can include primitive types (e.g., 'string', 'int'), arrays, or other Union instances. + * + * Example: + * ```php + * #[Union('string', 'null', 'date', new Union('boolean', 'int'))] + * ``` + */ + public function __construct(string|Union|array ...$types) + { + $this->types = $types; + } + + /** + * Converts the Union type to a string representation. + * + * @return string A string representation of the union types. + */ + public function __toString(): string + { + return implode(' | ', array_map(function ($type) { + if (is_string($type)) { + return $type; + } elseif ($type instanceof Union) { + return (string) $type; // Recursively handle nested unions + } elseif (is_array($type)) { + return 'array'; // Handle arrays + } + }, $this->types)); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Errors/Types/UnauthorizedRequestErrorBody.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Errors/Types/UnauthorizedRequestErrorBody.php new file mode 100644 index 000000000000..131d5f01b080 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Errors/Types/UnauthorizedRequestErrorBody.php @@ -0,0 +1,34 @@ +message = $values['message']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Exceptions/SeedApiException.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Exceptions/SeedApiException.php new file mode 100644 index 000000000000..6d0bba7c39b3 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Exceptions/SeedApiException.php @@ -0,0 +1,53 @@ +body = $body; + parent::__construct($message, $statusCode, $previous); + } + + /** + * Returns the body of the response that triggered the exception. + * + * @return mixed + */ + public function getBody(): mixed + { + return $this->body; + } + + /** + * @return string + */ + public function __toString(): string + { + if (empty($this->body)) { + return $this->message . '; Status Code: ' . $this->getCode() . "\n"; + } + return $this->message . '; Status Code: ' . $this->getCode() . '; Body: ' . print_r($this->body, true) . "\n"; + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Exceptions/SeedException.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Exceptions/SeedException.php new file mode 100644 index 000000000000..457035276737 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Exceptions/SeedException.php @@ -0,0 +1,12 @@ +, + * } $options @phpstan-ignore-next-line Property is used in endpoint methods via HttpEndpointGenerator + */ + private array $options; + + /** + * @var RawClient $client + */ + private RawClient $client; + + /** + * @param string $username The username to use for authentication. + * @param ?array{ + * baseUrl?: string, + * client?: ClientInterface, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * } $options + */ + public function __construct( + string $username, + ?array $options = null, + ) { + $defaultHeaders = [ + 'X-Fern-Language' => 'PHP', + 'X-Fern-SDK-Name' => 'Seed', + 'X-Fern-SDK-Version' => '0.0.1', + 'User-Agent' => 'seed/seed/0.0.1', + ]; + $defaultHeaders['Authorization'] = "Basic " . base64_encode($username . ":"); + + $this->options = $options ?? []; + + $this->options['headers'] = array_merge( + $defaultHeaders, + $this->options['headers'] ?? [], + ); + + $this->client = new RawClient( + options: $this->options, + ); + + $this->basicAuth = new BasicAuthClient($this->client, $this->options); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Utils/File.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Utils/File.php new file mode 100644 index 000000000000..ee2af27b8909 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/Utils/File.php @@ -0,0 +1,129 @@ +filename = $filename; + $this->contentType = $contentType; + $this->stream = $stream; + } + + /** + * Creates a File instance from a filepath. + * + * @param string $filepath + * @param ?string $filename + * @param ?string $contentType + * @return File + * @throws Exception + */ + public static function createFromFilepath( + string $filepath, + ?string $filename = null, + ?string $contentType = null, + ): File { + $resource = @fopen($filepath, 'r'); + if (!$resource) { + throw new Exception("Unable to open file $filepath"); + } + $stream = Psr17FactoryDiscovery::findStreamFactory()->createStreamFromResource($resource); + if (!$stream->isReadable()) { + throw new Exception("File $filepath is not readable"); + } + return new self( + stream: $stream, + filename: $filename ?? basename($filepath), + contentType: $contentType, + ); + } + + /** + * Creates a File instance from a string. + * + * @param string $content + * @param ?string $filename + * @param ?string $contentType + * @return File + */ + public static function createFromString( + string $content, + ?string $filename, + ?string $contentType = null, + ): File { + return new self( + stream: Psr17FactoryDiscovery::findStreamFactory()->createStream($content), + filename: $filename, + contentType: $contentType, + ); + } + + /** + * Maps this File into a multipart form data part. + * + * @param string $name The name of the multipart form data part. + * @param ?string $contentType Overrides the Content-Type associated with the file, if any. + * @return MultipartFormDataPart + */ + public function toMultipartFormDataPart(string $name, ?string $contentType = null): MultipartFormDataPart + { + $contentType ??= $this->contentType; + $headers = $contentType !== null + ? ['Content-Type' => $contentType] + : null; + + return new MultipartFormDataPart( + name: $name, + value: $this->stream, + filename: $this->filename, + headers: $headers, + ); + } + + /** + * Closes the file stream. + */ + public function close(): void + { + $this->stream->close(); + } + + /** + * Destructor to ensure stream is closed. + */ + public function __destruct() + { + try { + $this->close(); + } catch (\Throwable) { + // Swallow errors during garbage collection to avoid fatal errors. + } + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example0/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example0/snippet.php new file mode 100644 index 000000000000..70426907841c --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example0/snippet.php @@ -0,0 +1,13 @@ +', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->getWithBasicAuth(); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example1/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example1/snippet.php new file mode 100644 index 000000000000..70426907841c --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example1/snippet.php @@ -0,0 +1,13 @@ +', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->getWithBasicAuth(); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example2/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example2/snippet.php new file mode 100644 index 000000000000..70426907841c --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example2/snippet.php @@ -0,0 +1,13 @@ +', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->getWithBasicAuth(); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example3/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example3/snippet.php new file mode 100644 index 000000000000..50c374428362 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example3/snippet.php @@ -0,0 +1,17 @@ +', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example4/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example4/snippet.php new file mode 100644 index 000000000000..50c374428362 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example4/snippet.php @@ -0,0 +1,17 @@ +', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example5/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example5/snippet.php new file mode 100644 index 000000000000..50c374428362 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example5/snippet.php @@ -0,0 +1,17 @@ +', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example6/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example6/snippet.php new file mode 100644 index 000000000000..50c374428362 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/src/dynamic-snippets/example6/snippet.php @@ -0,0 +1,17 @@ +', + options: [ + 'baseUrl' => 'https://api.fern.com', + ], +); +$client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], +); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Client/RawClientTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Client/RawClientTest.php new file mode 100644 index 000000000000..df36dc918894 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Client/RawClientTest.php @@ -0,0 +1,1074 @@ +name = $values['name']; + } + + /** + * @return string + */ + public function getName(): ?string + { + return $this->name; + } +} + +class RawClientTest extends TestCase +{ + private string $baseUrl = 'https://api.example.com'; + private MockHttpClient $mockClient; + private RawClient $rawClient; + + protected function setUp(): void + { + $this->mockClient = new MockHttpClient(); + $this->rawClient = new RawClient(['client' => $this->mockClient, 'maxRetries' => 0]); + } + + /** + * @throws ClientExceptionInterface + */ + public function testHeaders(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ['X-Custom-Header' => 'TestValue'] + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('TestValue', $lastRequest->getHeaderLine('X-Custom-Header')); + } + + /** + * @throws ClientExceptionInterface + */ + public function testQueryParameters(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + [], + ['param1' => 'value1', 'param2' => ['a', 'b'], 'param3' => 'true'] + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals( + 'https://api.example.com/test?param1=value1¶m2=a¶m2=b¶m3=true', + (string)$lastRequest->getUri() + ); + } + + /** + * @throws ClientExceptionInterface + */ + public function testJsonBody(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = ['key' => 'value']; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + [], + $body + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals(JsonEncoder::encode($body), (string)$lastRequest->getBody()); + } + + public function testAdditionalHeaders(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = new JsonRequest([ + 'name' => 'john.doe' + ]); + $headers = [ + 'X-API-Version' => '1.0.0', + ]; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + $headers, + [], + $body + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'headers' => [ + 'X-Tenancy' => 'test' + ] + ] + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('1.0.0', $lastRequest->getHeaderLine('X-API-Version')); + $this->assertEquals('test', $lastRequest->getHeaderLine('X-Tenancy')); + } + + public function testOverrideAdditionalHeaders(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = new JsonRequest([ + 'name' => 'john.doe' + ]); + $headers = [ + 'X-API-Version' => '1.0.0', + ]; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + $headers, + [], + $body + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'headers' => [ + 'X-API-Version' => '2.0.0' + ] + ] + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('2.0.0', $lastRequest->getHeaderLine('X-API-Version')); + } + + public function testAdditionalBodyProperties(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = new JsonRequest([ + 'name' => 'john.doe' + ]); + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + [], + $body + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'bodyProperties' => [ + 'age' => 42 + ] + ] + ); + + $expectedJson = [ + 'name' => 'john.doe', + 'age' => 42 + ]; + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals(JsonEncoder::encode($expectedJson), (string)$lastRequest->getBody()); + } + + public function testOverrideAdditionalBodyProperties(): void + { + $this->mockClient->append(self::createResponse(200)); + + $body = [ + 'name' => 'john.doe' + ]; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + [], + $body + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'bodyProperties' => [ + 'name' => 'jane.doe' + ] + ] + ); + + $expectedJson = [ + 'name' => 'jane.doe', + ]; + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals(JsonEncoder::encode($expectedJson), (string)$lastRequest->getBody()); + } + + public function testAdditionalQueryParameters(): void + { + $this->mockClient->append(self::createResponse(200)); + + $query = ['key' => 'value']; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + $query, + [] + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'queryParameters' => [ + 'extra' => 42 + ] + ] + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('key=value&extra=42', $lastRequest->getUri()->getQuery()); + } + + public function testOverrideQueryParameters(): void + { + $this->mockClient->append(self::createResponse(200)); + + $query = ['key' => 'invalid']; + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + $query, + [] + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'queryParameters' => [ + 'key' => 'value' + ] + ] + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); + $this->assertEquals('key=value', $lastRequest->getUri()->getQuery()); + } + + public function testDefaultRetries(): void + { + $this->mockClient->append(self::createResponse(500)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET + ); + + $response = $this->rawClient->sendRequest($request); + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals(0, $this->mockClient->count()); + } + + /** + * @throws ClientExceptionInterface + */ + public function testExplicitRetriesSuccess(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(500), self::createResponse(500), self::createResponse(200)); + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->sendRequest($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(0, $mockClient->count()); + } + + public function testExplicitRetriesFailure(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(500), self::createResponse(500), self::createResponse(500)); + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->sendRequest($request); + + $this->assertEquals(500, $response->getStatusCode()); + $this->assertEquals(0, $mockClient->count()); + } + + /** + * @throws ClientExceptionInterface + */ + public function testShouldRetryOnStatusCodes(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(408), + self::createResponse(429), + self::createResponse(500), + self::createResponse(501), + self::createResponse(502), + self::createResponse(503), + self::createResponse(504), + self::createResponse(505), + self::createResponse(599), + self::createResponse(200), + ); + $countOfErrorRequests = $mockClient->count() - 1; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: $countOfErrorRequests, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->sendRequest($request); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals(0, $mockClient->count()); + } + + public function testShouldFailOn400Response(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(400), self::createResponse(200)); + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->sendRequest($request); + + $this->assertEquals(400, $response->getStatusCode()); + $this->assertEquals(1, $mockClient->count()); + } + + public function testRetryAfterSecondsHeaderControlsDelay(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503, ['Retry-After' => '10']), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); // Convert microseconds to milliseconds + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(10000, $capturedDelays[0]); + $this->assertLessThanOrEqual(12000, $capturedDelays[0]); + } + + public function testRetryAfterHttpDateHeaderIsHandled(): void + { + $retryAfterDate = gmdate('D, d M Y H:i:s \G\M\T', time() + 5); + + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503, ['Retry-After' => $retryAfterDate]), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThan(0, $capturedDelays[0]); + $this->assertLessThanOrEqual(60000, $capturedDelays[0]); + } + + public function testRateLimitResetHeaderControlsDelay(): void + { + $resetTime = (int) floor(microtime(true)) + 5; + + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(429, ['X-RateLimit-Reset' => (string) $resetTime]), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThan(0, $capturedDelays[0]); + $this->assertLessThanOrEqual(60000, $capturedDelays[0]); + } + + public function testRateLimitResetHeaderRespectsMaxDelayAndPositiveJitter(): void + { + $resetTime = (int) floor(microtime(true)) + 1000; + + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(429, ['X-RateLimit-Reset' => (string) $resetTime]), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 1, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(60000, $capturedDelays[0]); + $this->assertLessThanOrEqual(72000, $capturedDelays[0]); + } + + public function testExponentialBackoffWithSymmetricJitterWhenNoHeaders(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 1, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(900, $capturedDelays[0]); + $this->assertLessThanOrEqual(1100, $capturedDelays[0]); + } + + public function testRetryAfterHeaderTakesPrecedenceOverRateLimitReset(): void + { + $resetTime = (int) floor(microtime(true)) + 30; + + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503, [ + 'Retry-After' => '5', + 'X-RateLimit-Reset' => (string) $resetTime, + ]), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(5000, $capturedDelays[0]); + $this->assertLessThanOrEqual(6000, $capturedDelays[0]); + } + + public function testMaxDelayCapIsApplied(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append( + self::createResponse(503, ['Retry-After' => '120']), + self::createResponse(200), + ); + + $capturedDelays = []; + $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { + $capturedDelays[] = (int) ($microseconds / 1000); + }; + + $retryClient = new RetryDecoratingClient( + $mockClient, + maxRetries: 2, + baseDelay: 1000, + sleepFunction: $sleepFunction, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $retryClient->sendRequest($request); + + $this->assertCount(1, $capturedDelays); + $this->assertGreaterThanOrEqual(60000, $capturedDelays[0]); + $this->assertLessThanOrEqual(72000, $capturedDelays[0]); + } + + public function testMultipartContentTypeIncludesBoundary(): void + { + $this->mockClient->append(self::createResponse(200)); + + $formData = new MultipartFormData(); + $formData->add('field', 'value'); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + [], + [], + $formData, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $contentType = $lastRequest->getHeaderLine('Content-Type'); + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + + $boundary = substr($contentType, strlen('multipart/form-data; boundary=')); + $body = (string) $lastRequest->getBody(); + $this->assertStringContainsString("--{$boundary}\r\n", $body); + $this->assertStringContainsString("Content-Disposition: form-data; name=\"field\"\r\n", $body); + $this->assertStringContainsString("value", $body); + $this->assertStringContainsString("--{$boundary}--\r\n", $body); + } + + public function testMultipartWithFilename(): void + { + $this->mockClient->append(self::createResponse(200)); + + $formData = new MultipartFormData(); + $formData->addPart(new MultipartFormDataPart( + name: 'document', + value: 'file-contents', + filename: 'report.pdf', + headers: ['Content-Type' => 'application/pdf'], + )); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + [], + [], + $formData, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $body = (string) $lastRequest->getBody(); + $this->assertStringContainsString( + 'Content-Disposition: form-data; name="document"; filename="report.pdf"', + $body, + ); + $this->assertStringContainsString('Content-Type: application/pdf', $body); + $this->assertStringContainsString('file-contents', $body); + } + + public function testMultipartWithMultipleParts(): void + { + $this->mockClient->append(self::createResponse(200)); + + $formData = new MultipartFormData(); + $formData->add('name', 'John'); + $formData->add('age', 30); + $formData->addPart(new MultipartFormDataPart( + name: 'avatar', + value: 'image-data', + filename: 'avatar.png', + )); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/profile', + HttpMethod::POST, + [], + [], + $formData, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $body = (string) $lastRequest->getBody(); + $this->assertStringContainsString('name="name"', $body); + $this->assertStringContainsString('John', $body); + $this->assertStringContainsString('name="age"', $body); + $this->assertStringContainsString('30', $body); + $this->assertStringContainsString('name="avatar"; filename="avatar.png"', $body); + $this->assertStringContainsString('image-data', $body); + } + + public function testMultipartDoesNotIncludeJsonContentType(): void + { + $this->mockClient->append(self::createResponse(200)); + + $formData = new MultipartFormData(); + $formData->add('field', 'value'); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + [], + [], + $formData, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $contentType = $lastRequest->getHeaderLine('Content-Type'); + $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); + $this->assertStringNotContainsString('application/json', $contentType); + } + + public function testMultipartNullBodySendsNoBody(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $this->assertEquals('', (string) $lastRequest->getBody()); + $this->assertStringNotContainsString('multipart/form-data', $lastRequest->getHeaderLine('Content-Type')); + } + + public function testJsonNullBodySendsNoBody(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $this->assertEquals('', (string) $lastRequest->getBody()); + } + + public function testEmptyJsonBodySerializesAsObject(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::POST, + [], + [], + ['key' => 'value'], + ); + + $this->rawClient->sendRequest( + $request, + options: [ + 'bodyProperties' => [ + 'key' => 'value', + ], + ], + ); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + // When bodyProperties override all keys, the merged result should still + // serialize as a JSON object {}, not an array []. + $decoded = json_decode((string) $lastRequest->getBody(), true); + $this->assertIsArray($decoded); + $this->assertEquals('value', $decoded['key']); + } + + public function testAuthHeadersAreIncluded(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(200)); + + $rawClient = new RawClient([ + 'client' => $mockClient, + 'maxRetries' => 0, + 'getAuthHeaders' => fn () => ['Authorization' => 'Bearer test-token'], + ]); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ); + + $rawClient->sendRequest($request); + + $lastRequest = $mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $this->assertEquals('Bearer test-token', $lastRequest->getHeaderLine('Authorization')); + } + + public function testAuthHeadersAreIncludedInMultipart(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(200)); + + $rawClient = new RawClient([ + 'client' => $mockClient, + 'maxRetries' => 0, + 'getAuthHeaders' => fn () => ['Authorization' => 'Bearer test-token'], + ]); + + $formData = new MultipartFormData(); + $formData->add('field', 'value'); + + $request = new MultipartApiRequest( + $this->baseUrl, + '/upload', + HttpMethod::POST, + [], + [], + $formData, + ); + + $rawClient->sendRequest($request); + + $lastRequest = $mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + + $this->assertEquals('Bearer test-token', $lastRequest->getHeaderLine('Authorization')); + $this->assertStringStartsWith('multipart/form-data; boundary=', $lastRequest->getHeaderLine('Content-Type')); + } + + /** + * Creates a PSR-7 response using discovery, without depending on any specific implementation. + * + * @param int $statusCode + * @param array $headers + * @param string $body + * @return ResponseInterface + */ + private static function createResponse( + int $statusCode = 200, + array $headers = [], + string $body = '', + ): ResponseInterface { + $response = \Http\Discovery\Psr17FactoryDiscovery::findResponseFactory() + ->createResponse($statusCode); + foreach ($headers as $name => $value) { + $response = $response->withHeader($name, $value); + } + if ($body !== '') { + $response = $response->withBody( + \Http\Discovery\Psr17FactoryDiscovery::findStreamFactory() + ->createStream($body), + ); + } + return $response; + } + + + public function testTimeoutOptionIsAccepted(): void + { + $this->mockClient->append(self::createResponse(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ); + + // MockHttpClient is not Guzzle/Symfony, so a warning is triggered once. + set_error_handler(static function (int $errno, string $errstr): bool { + return $errno === E_USER_WARNING + && str_contains($errstr, 'Timeout option is not supported'); + }); + + try { + $response = $this->rawClient->sendRequest( + $request, + options: [ + 'timeout' => 3.0 + ] + ); + + $this->assertEquals(200, $response->getStatusCode()); + + $lastRequest = $this->mockClient->getLastRequest(); + $this->assertInstanceOf(RequestInterface::class, $lastRequest); + } finally { + restore_error_handler(); + } + } + + public function testClientLevelTimeoutIsAccepted(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(200)); + + $rawClient = new RawClient([ + 'client' => $mockClient, + 'maxRetries' => 0, + 'timeout' => 5.0, + ]); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ); + + set_error_handler(static function (int $errno, string $errstr): bool { + return $errno === E_USER_WARNING + && str_contains($errstr, 'Timeout option is not supported'); + }); + + try { + $response = $rawClient->sendRequest($request); + $this->assertEquals(200, $response->getStatusCode()); + } finally { + restore_error_handler(); + } + } + + public function testPerRequestTimeoutOverridesClientTimeout(): void + { + $mockClient = new MockHttpClient(); + $mockClient->append(self::createResponse(200)); + + $rawClient = new RawClient([ + 'client' => $mockClient, + 'maxRetries' => 0, + 'timeout' => 5.0, + ]); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + ); + + set_error_handler(static function (int $errno, string $errstr): bool { + return $errno === E_USER_WARNING + && str_contains($errstr, 'Timeout option is not supported'); + }); + + try { + $response = $rawClient->sendRequest( + $request, + options: [ + 'timeout' => 1.0 + ] + ); + + $this->assertEquals(200, $response->getStatusCode()); + } finally { + restore_error_handler(); + } + } + + public function testDiscoveryFindsHttpClient(): void + { + // HttpClientBuilder::build() with no client arg uses Psr18ClientDiscovery. + $client = HttpClientBuilder::build(); + $this->assertInstanceOf(\Psr\Http\Client\ClientInterface::class, $client); + } + + public function testDiscoveryFindsFactories(): void + { + $requestFactory = HttpClientBuilder::requestFactory(); + $this->assertInstanceOf(\Psr\Http\Message\RequestFactoryInterface::class, $requestFactory); + + $streamFactory = HttpClientBuilder::streamFactory(); + $this->assertInstanceOf(\Psr\Http\Message\StreamFactoryInterface::class, $streamFactory); + + // Verify they produce usable objects + $request = $requestFactory->createRequest('GET', 'https://example.com'); + $this->assertEquals('GET', $request->getMethod()); + + $stream = $streamFactory->createStream('hello'); + $this->assertEquals('hello', (string) $stream); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/AdditionalPropertiesTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/AdditionalPropertiesTest.php new file mode 100644 index 000000000000..2c32002340e7 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/AdditionalPropertiesTest.php @@ -0,0 +1,76 @@ +name; + } + + /** + * @return string|null + */ + public function getEmail(): ?string + { + return $this->email; + } + + /** + * @param array{ + * name: string, + * email?: string|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->name = $values['name']; + $this->email = $values['email'] ?? null; + } +} + +class AdditionalPropertiesTest extends TestCase +{ + public function testExtraProperties(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'name' => 'john.doe', + 'email' => 'john.doe@example.com', + 'age' => 42 + ], + ); + + $person = Person::fromJson($expectedJson); + $this->assertEquals('john.doe', $person->getName()); + $this->assertEquals('john.doe@example.com', $person->getEmail()); + $this->assertEquals( + [ + 'age' => 42 + ], + $person->getAdditionalProperties(), + ); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/DateArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/DateArrayTest.php new file mode 100644 index 000000000000..e7794d652432 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/DateArrayTest.php @@ -0,0 +1,54 @@ +dates = $values['dates']; + } +} + +class DateArrayTest extends TestCase +{ + public function testDateTimeInArrays(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'dates' => ['2023-01-01', '2023-02-01', '2023-03-01'] + ], + ); + + $object = DateArray::fromJson($expectedJson); + $this->assertInstanceOf(DateTime::class, $object->dates[0], 'dates[0] should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->dates[0]->format('Y-m-d'), 'dates[0] should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->dates[1], 'dates[1] should be a DateTime instance.'); + $this->assertEquals('2023-02-01', $object->dates[1]->format('Y-m-d'), 'dates[1] should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->dates[2], 'dates[2] should be a DateTime instance.'); + $this->assertEquals('2023-03-01', $object->dates[2]->format('Y-m-d'), 'dates[2] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for dates array.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/EmptyArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/EmptyArrayTest.php new file mode 100644 index 000000000000..b5f217e01f76 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/EmptyArrayTest.php @@ -0,0 +1,71 @@ + $emptyMapArray + */ + #[JsonProperty('empty_map_array')] + #[ArrayType(['integer' => new Union('string', 'null')])] + public array $emptyMapArray; + + /** + * @var array $emptyDatesArray + */ + #[ArrayType([new Union('date', 'null')])] + #[JsonProperty('empty_dates_array')] + public array $emptyDatesArray; + + /** + * @param array{ + * emptyStringArray: string[], + * emptyMapArray: array, + * emptyDatesArray: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->emptyStringArray = $values['emptyStringArray']; + $this->emptyMapArray = $values['emptyMapArray']; + $this->emptyDatesArray = $values['emptyDatesArray']; + } +} + +class EmptyArrayTest extends TestCase +{ + public function testEmptyArray(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'empty_string_array' => [], + 'empty_map_array' => [], + 'empty_dates_array' => [] + ], + ); + + $object = EmptyArray::fromJson($expectedJson); + $this->assertEmpty($object->emptyStringArray, 'empty_string_array should be empty.'); + $this->assertEmpty($object->emptyMapArray, 'empty_map_array should be empty.'); + $this->assertEmpty($object->emptyDatesArray, 'empty_dates_array should be empty.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for EmptyArraysType.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/EnumTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/EnumTest.php new file mode 100644 index 000000000000..72dc6f2cfa00 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/EnumTest.php @@ -0,0 +1,77 @@ +value; + } +} + +class ShapeType extends JsonSerializableType +{ + /** + * @var Shape $shape + */ + #[JsonProperty('shape')] + public Shape $shape; + + /** + * @var Shape[] $shapes + */ + #[ArrayType([Shape::class])] + #[JsonProperty('shapes')] + public array $shapes; + + /** + * @param Shape $shape + * @param Shape[] $shapes + */ + public function __construct( + Shape $shape, + array $shapes, + ) { + $this->shape = $shape; + $this->shapes = $shapes; + } +} + +class EnumTest extends TestCase +{ + public function testEnumSerialization(): void + { + $object = new ShapeType( + Shape::Circle, + [Shape::Square, Shape::Circle, Shape::Triangle] + ); + + $expectedJson = JsonEncoder::encode([ + 'shape' => 'CIRCLE', + 'shapes' => ['SQUARE', 'CIRCLE', 'TRIANGLE'] + ]); + + $actualJson = $object->toJson(); + + $this->assertJsonStringEqualsJsonString( + $expectedJson, + $actualJson, + 'Serialized JSON does not match expected JSON for shape and shapes properties.' + ); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/ExhaustiveTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/ExhaustiveTest.php new file mode 100644 index 000000000000..4c288378b48b --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/ExhaustiveTest.php @@ -0,0 +1,197 @@ +nestedProperty = $values['nestedProperty']; + } +} + +class Type extends JsonSerializableType +{ + /** + * @var Nested nestedType + */ + #[JsonProperty('nested_type')] + public Nested $nestedType; /** + + * @var string $simpleProperty + */ + #[JsonProperty('simple_property')] + public string $simpleProperty; + + /** + * @var DateTime $dateProperty + */ + #[Date(Date::TYPE_DATE)] + #[JsonProperty('date_property')] + public DateTime $dateProperty; + + /** + * @var DateTime $datetimeProperty + */ + #[Date(Date::TYPE_DATETIME)] + #[JsonProperty('datetime_property')] + public DateTime $datetimeProperty; + + /** + * @var array $stringArray + */ + #[ArrayType(['string'])] + #[JsonProperty('string_array')] + public array $stringArray; + + /** + * @var array $mapProperty + */ + #[ArrayType(['string' => 'integer'])] + #[JsonProperty('map_property')] + public array $mapProperty; + + /** + * @var array $objectArray + */ + #[ArrayType(['integer' => new Union(Nested::class, 'null')])] + #[JsonProperty('object_array')] + public array $objectArray; + + /** + * @var array> $nestedArray + */ + #[ArrayType(['integer' => ['integer' => new Union('string', 'null')]])] + #[JsonProperty('nested_array')] + public array $nestedArray; + + /** + * @var array $datesArray + */ + #[ArrayType([new Union('date', 'null')])] + #[JsonProperty('dates_array')] + public array $datesArray; + + /** + * @var string|null $nullableProperty + */ + #[JsonProperty('nullable_property')] + public ?string $nullableProperty; + + /** + * @param array{ + * nestedType: Nested, + * simpleProperty: string, + * dateProperty: DateTime, + * datetimeProperty: DateTime, + * stringArray: array, + * mapProperty: array, + * objectArray: array, + * nestedArray: array>, + * datesArray: array, + * nullableProperty?: string|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nestedType = $values['nestedType']; + $this->simpleProperty = $values['simpleProperty']; + $this->dateProperty = $values['dateProperty']; + $this->datetimeProperty = $values['datetimeProperty']; + $this->stringArray = $values['stringArray']; + $this->mapProperty = $values['mapProperty']; + $this->objectArray = $values['objectArray']; + $this->nestedArray = $values['nestedArray']; + $this->datesArray = $values['datesArray']; + $this->nullableProperty = $values['nullableProperty'] ?? null; + } +} + +class ExhaustiveTest extends TestCase +{ + /** + * Test serialization and deserialization of all types in Type. + */ + public function testExhaustive(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'nested_type' => ['nested_property' => '1995-07-20'], + 'simple_property' => 'Test String', + // Omit 'nullable_property' to test null serialization + 'date_property' => '2023-01-01', + 'datetime_property' => '2023-01-01T12:34:56Z', + 'string_array' => ['one', 'two', 'three'], + 'map_property' => ['key1' => 1, 'key2' => 2], + 'object_array' => [ + 1 => ['nested_property' => '2021-07-20'], + 2 => null, // Testing nullable objects in array + ], + 'nested_array' => [ + 1 => [1 => 'value1', 2 => null], // Testing nullable strings in nested array + 2 => [3 => 'value3', 4 => 'value4'] + ], + 'dates_array' => ['2023-01-01', null, '2023-03-01'] // Testing nullable dates in array> + ], + ); + + $object = Type::fromJson($expectedJson); + + // Check that nullable property is null and not included in JSON + $this->assertNull($object->nullableProperty, 'Nullable property should be null.'); + + // Check date properties + $this->assertInstanceOf(DateTime::class, $object->dateProperty, 'date_property should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->dateProperty->format('Y-m-d'), 'date_property should have the correct date.'); + $this->assertInstanceOf(DateTime::class, $object->datetimeProperty, 'datetime_property should be a DateTime instance.'); + $this->assertEquals('2023-01-01 12:34:56', $object->datetimeProperty->format('Y-m-d H:i:s'), 'datetime_property should have the correct datetime.'); + + // Check scalar arrays + $this->assertEquals(['one', 'two', 'three'], $object->stringArray, 'string_array should match the original data.'); + $this->assertEquals(['key1' => 1, 'key2' => 2], $object->mapProperty, 'map_property should match the original data.'); + + // Check object array with nullable elements + $this->assertInstanceOf(Nested::class, $object->objectArray[1], 'object_array[1] should be an instance of TestNestedType1.'); + $this->assertEquals('2021-07-20', $object->objectArray[1]->nestedProperty->format('Y-m-d'), 'object_array[1]->nestedProperty should match the original data.'); + $this->assertNull($object->objectArray[2], 'object_array[2] should be null.'); + + // Check nested array with nullable strings + $this->assertEquals('value1', $object->nestedArray[1][1], 'nested_array[1][1] should match the original data.'); + $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); + $this->assertEquals('value3', $object->nestedArray[2][3], 'nested_array[2][3] should match the original data.'); + $this->assertEquals('value4', $object->nestedArray[2][4], 'nested_array[2][4] should match the original data.'); + + // Check dates array with nullable DateTime objects + $this->assertInstanceOf(DateTime::class, $object->datesArray[0], 'dates_array[0] should be a DateTime instance.'); + $this->assertEquals('2023-01-01', $object->datesArray[0]->format('Y-m-d'), 'dates_array[0] should have the correct date.'); + $this->assertNull($object->datesArray[1], 'dates_array[1] should be null.'); + $this->assertInstanceOf(DateTime::class, $object->datesArray[2], 'dates_array[2] should be a DateTime instance.'); + $this->assertEquals('2023-03-01', $object->datesArray[2]->format('Y-m-d'), 'dates_array[2] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'The serialized JSON does not match the original JSON.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/InvalidTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/InvalidTest.php new file mode 100644 index 000000000000..9d845ea113b8 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/InvalidTest.php @@ -0,0 +1,42 @@ +integerProperty = $values['integerProperty']; + } +} + +class InvalidTest extends TestCase +{ + public function testInvalidJsonThrowsException(): void + { + $this->expectException(\TypeError::class); + $json = JsonEncoder::encode( + [ + 'integer_property' => 'not_an_integer' + ], + ); + Invalid::fromJson($json); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NestedUnionArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NestedUnionArrayTest.php new file mode 100644 index 000000000000..8fbbeb939f02 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NestedUnionArrayTest.php @@ -0,0 +1,89 @@ +nestedProperty = $values['nestedProperty']; + } +} + +class NestedUnionArray extends JsonSerializableType +{ + /** + * @var array> $nestedArray + */ + #[ArrayType(['integer' => ['integer' => new Union(UnionObject::class, 'null', 'date')]])] + #[JsonProperty('nested_array')] + public array $nestedArray; + + /** + * @param array{ + * nestedArray: array>, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nestedArray = $values['nestedArray']; + } +} + +class NestedUnionArrayTest extends TestCase +{ + public function testNestedUnionArray(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'nested_array' => [ + 1 => [ + 1 => ['nested_property' => 'Nested One'], + 2 => null, + 4 => '2023-01-02' + ], + 2 => [ + 5 => ['nested_property' => 'Nested Two'], + 7 => '2023-02-02' + ] + ] + ], + ); + + $object = NestedUnionArray::fromJson($expectedJson); + $this->assertInstanceOf(UnionObject::class, $object->nestedArray[1][1], 'nested_array[1][1] should be an instance of Object.'); + $this->assertEquals('Nested One', $object->nestedArray[1][1]->nestedProperty, 'nested_array[1][1]->nestedProperty should match the original data.'); + $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); + $this->assertInstanceOf(DateTime::class, $object->nestedArray[1][4], 'nested_array[1][4] should be a DateTime instance.'); + $this->assertEquals('2023-01-02T00:00:00+00:00', $object->nestedArray[1][4]->format(Constant::DateTimeFormat), 'nested_array[1][4] should have the correct datetime.'); + $this->assertInstanceOf(UnionObject::class, $object->nestedArray[2][5], 'nested_array[2][5] should be an instance of Object.'); + $this->assertEquals('Nested Two', $object->nestedArray[2][5]->nestedProperty, 'nested_array[2][5]->nestedProperty should match the original data.'); + $this->assertInstanceOf(DateTime::class, $object->nestedArray[2][7], 'nested_array[1][4] should be a DateTime instance.'); + $this->assertEquals('2023-02-02', $object->nestedArray[2][7]->format('Y-m-d'), 'nested_array[1][4] should have the correct date.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nested_array.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NullPropertyTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NullPropertyTest.php new file mode 100644 index 000000000000..ce20a2442825 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NullPropertyTest.php @@ -0,0 +1,53 @@ +nonNullProperty = $values['nonNullProperty']; + $this->nullProperty = $values['nullProperty'] ?? null; + } +} + +class NullPropertyTest extends TestCase +{ + public function testNullPropertiesAreOmitted(): void + { + $object = new NullProperty( + [ + "nonNullProperty" => "Test String", + "nullProperty" => null + ] + ); + + $serialized = $object->jsonSerialize(); + $this->assertArrayHasKey('non_null_property', $serialized, 'non_null_property should be present in the serialized JSON.'); + $this->assertArrayNotHasKey('null_property', $serialized, 'null_property should be omitted from the serialized JSON.'); + $this->assertEquals('Test String', $serialized['non_null_property'], 'non_null_property should have the correct value.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NullableArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NullableArrayTest.php new file mode 100644 index 000000000000..d1749c434a4c --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/NullableArrayTest.php @@ -0,0 +1,49 @@ + $nullableStringArray + */ + #[ArrayType([new Union('string', 'null')])] + #[JsonProperty('nullable_string_array')] + public array $nullableStringArray; + + /** + * @param array{ + * nullableStringArray: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->nullableStringArray = $values['nullableStringArray']; + } +} + +class NullableArrayTest extends TestCase +{ + public function testNullableArray(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'nullable_string_array' => ['one', null, 'three'] + ], + ); + + $object = NullableArray::fromJson($expectedJson); + $this->assertEquals(['one', null, 'three'], $object->nullableStringArray, 'nullable_string_array should match the original data.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nullable_string_array.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/ScalarTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/ScalarTest.php new file mode 100644 index 000000000000..ad4db0251bb5 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/ScalarTest.php @@ -0,0 +1,116 @@ + $intFloatArray + */ + #[ArrayType([new Union('integer', 'float')])] + #[JsonProperty('int_float_array')] + public array $intFloatArray; + + /** + * @var array $floatArray + */ + #[ArrayType(['float'])] + #[JsonProperty('float_array')] + public array $floatArray; + + /** + * @var bool|null $nullableBooleanProperty + */ + #[JsonProperty('nullable_boolean_property')] + public ?bool $nullableBooleanProperty; + + /** + * @param array{ + * integerProperty: int, + * floatProperty: float, + * otherFloatProperty: float, + * booleanProperty: bool, + * stringProperty: string, + * intFloatArray: array, + * floatArray: array, + * nullableBooleanProperty?: bool|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->integerProperty = $values['integerProperty']; + $this->floatProperty = $values['floatProperty']; + $this->otherFloatProperty = $values['otherFloatProperty']; + $this->booleanProperty = $values['booleanProperty']; + $this->stringProperty = $values['stringProperty']; + $this->intFloatArray = $values['intFloatArray']; + $this->floatArray = $values['floatArray']; + $this->nullableBooleanProperty = $values['nullableBooleanProperty'] ?? null; + } +} + +class ScalarTest extends TestCase +{ + public function testAllScalarTypesIncludingFloat(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'integer_property' => 42, + 'float_property' => 3.14159, + 'other_float_property' => 3, + 'boolean_property' => true, + 'string_property' => 'Hello, World!', + 'int_float_array' => [1, 2.5, 3, 4.75], + 'float_array' => [1, 2, 3, 4] // Ensure we handle "integer-looking" floats + ], + ); + + $object = Scalar::fromJson($expectedJson); + $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); + $this->assertEquals(3.14159, $object->floatProperty, 'float_property should be 3.14159.'); + $this->assertTrue($object->booleanProperty, 'boolean_property should be true.'); + $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); + $this->assertNull($object->nullableBooleanProperty, 'nullable_boolean_property should be null.'); + $this->assertEquals([1, 2.5, 3, 4.75], $object->intFloatArray, 'int_float_array should match the original data.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTest.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/TraitTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/TraitTest.php new file mode 100644 index 000000000000..e18f06d4191b --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/TraitTest.php @@ -0,0 +1,60 @@ +integerProperty = $values['integerProperty']; + $this->stringProperty = $values['stringProperty']; + } +} + +class TraitTest extends TestCase +{ + public function testTraitPropertyAndString(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'integer_property' => 42, + 'string_property' => 'Hello, World!', + ], + ); + + $object = TypeWithTrait::fromJson($expectedJson); + $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); + $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTestWithTrait.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/UnionArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/UnionArrayTest.php new file mode 100644 index 000000000000..de20cf9fde1b --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/UnionArrayTest.php @@ -0,0 +1,57 @@ + $mixedDates + */ + #[ArrayType(['integer' => new Union('datetime', 'string', 'null')])] + #[JsonProperty('mixed_dates')] + public array $mixedDates; + + /** + * @param array{ + * mixedDates: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->mixedDates = $values['mixedDates']; + } +} + +class UnionArrayTest extends TestCase +{ + public function testUnionArray(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'mixed_dates' => [ + 1 => '2023-01-01T12:00:00Z', + 2 => null, + 3 => 'Some String' + ] + ], + ); + + $object = UnionArray::fromJson($expectedJson); + $this->assertInstanceOf(DateTime::class, $object->mixedDates[1], 'mixed_dates[1] should be a DateTime instance.'); + $this->assertEquals('2023-01-01 12:00:00', $object->mixedDates[1]->format('Y-m-d H:i:s'), 'mixed_dates[1] should have the correct datetime.'); + $this->assertNull($object->mixedDates[2], 'mixed_dates[2] should be null.'); + $this->assertEquals('Some String', $object->mixedDates[3], 'mixed_dates[3] should be "Some String".'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for mixed_dates.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/UnionPropertyTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/UnionPropertyTest.php new file mode 100644 index 000000000000..f733062cfabc --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Core/Json/UnionPropertyTest.php @@ -0,0 +1,111 @@ + 'integer'], UnionProperty::class)] + #[JsonProperty('complexUnion')] + public mixed $complexUnion; + + /** + * @param array{ + * complexUnion: string|int|null|array|UnionProperty + * } $values + */ + public function __construct( + array $values, + ) { + $this->complexUnion = $values['complexUnion']; + } +} + +class UnionPropertyTest extends TestCase +{ + public function testWithMapOfIntToInt(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'complexUnion' => [1 => 100, 2 => 200] + ], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsArray($object->complexUnion, 'complexUnion should be an array.'); + $this->assertEquals([1 => 100, 2 => 200], $object->complexUnion, 'complexUnion should match the original map of int => int.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithNestedUnionPropertyType(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'complexUnion' => new UnionProperty( + [ + 'complexUnion' => 'Nested String' + ] + ) + ], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertInstanceOf(UnionProperty::class, $object->complexUnion, 'complexUnion should be an instance of UnionPropertyType.'); + $this->assertEquals('Nested String', $object->complexUnion->complexUnion, 'Nested complexUnion should match the original value.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithNull(): void + { + $expectedJson = JsonEncoder::encode( + [], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertNull($object->complexUnion, 'complexUnion should be null.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithInteger(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'complexUnion' => 42 + ], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsInt($object->complexUnion, 'complexUnion should be an integer.'); + $this->assertEquals(42, $object->complexUnion, 'complexUnion should match the original integer.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } + + public function testWithString(): void + { + $expectedJson = JsonEncoder::encode( + [ + 'complexUnion' => 'Some String' + ], + ); + + $object = UnionProperty::fromJson($expectedJson); + $this->assertIsString($object->complexUnion, 'complexUnion should be a string.'); + $this->assertEquals('Some String', $object->complexUnion, 'complexUnion should match the original string.'); + + $actualJson = $object->toJson(); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php new file mode 100644 index 000000000000..2d804a182fff --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php @@ -0,0 +1,71 @@ +client->basicAuth->getWithBasicAuth( + [ + 'headers' => [ + 'X-Test-Id' => 'basic_auth.get_with_basic_auth.0', + ], + ], + ); + $this->verifyRequestCount( + $testId, + "GET", + "/basic-auth", + null, + 1 + ); + } + + /** + */ + public function testPostWithBasicAuth(): void { + $testId = 'basic_auth.post_with_basic_auth.0'; + $this->client->basicAuth->postWithBasicAuth( + [ + 'key' => "value", + ], + [ + 'headers' => [ + 'X-Test-Id' => 'basic_auth.post_with_basic_auth.0', + ], + ], + ); + $this->verifyRequestCount( + $testId, + "POST", + "/basic-auth", + null, + 1 + ); + } + + /** + */ + protected function setUp(): void { + parent::setUp(); + $wiremockUrl = getenv('WIREMOCK_URL') ?: 'http://localhost:8080'; + $this->client = new SeedClient( + username: 'test-user', + password: 'test-password', + options: [ + 'baseUrl' => $wiremockUrl, + ], + ); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/WireMockTestCase.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/WireMockTestCase.php new file mode 100644 index 000000000000..0ae28fccdabe --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/WireMockTestCase.php @@ -0,0 +1,80 @@ +|null $queryParams Query parameters to match + * @param int $expected Expected number of requests + */ + protected function verifyRequestCount( + string $testId, + string $method, + string $urlPath, + ?array $queryParams, + int $expected + ): void { + $client = Psr18ClientDiscovery::find(); + $requestFactory = Psr17FactoryDiscovery::findRequestFactory(); + $streamFactory = Psr17FactoryDiscovery::findStreamFactory(); + + $body = [ + 'method' => $method, + 'urlPath' => $urlPath, + 'headers' => [ + 'X-Test-Id' => ['equalTo' => $testId], + ], + ]; + if ($queryParams !== null && $queryParams !== []) { + $body['queryParameters'] = []; + foreach ($queryParams as $k => $v) { + $body['queryParameters'][$k] = ['equalTo' => (string) $v]; + } + } + + $wiremockUrl = getenv('WIREMOCK_URL') ?: 'http://localhost:8080'; + $request = $requestFactory->createRequest('POST', $wiremockUrl . '/__admin/requests/find') + ->withHeader('Content-Type', 'application/json') + ->withBody($streamFactory->createStream(JsonEncoder::encode($body))); + $response = $client->sendRequest($request); + + $this->assertSame(200, $response->getStatusCode(), 'Failed to query WireMock requests'); + + $json = json_decode((string) $response->getBody(), true); + + // Ensure we have an array; otherwise, fail the test. + if (!is_array($json)) { + $this->fail('Expected WireMock to return a JSON object.'); + } + + /** @var array $json */ + $requests = []; + if (isset($json['requests']) && is_array($json['requests'])) { + $requests = $json['requests']; + } + + /** @var array $requests */ + $this->assertCount( + $expected, + $requests, + sprintf('Expected %d requests, found %d', $expected, count($requests)) + ); + } +} diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/bootstrap.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/bootstrap.php new file mode 100644 index 000000000000..200266641318 --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/bootstrap.php @@ -0,0 +1,55 @@ +&1', + escapeshellarg($dockerComposeFile) +); +exec($cmd, $output, $exitCode); +if ($exitCode !== 0) { + throw new \RuntimeException("Failed to start WireMock: " . implode("\n", $output)); +} + +// Discover the dynamically assigned port +$portCmd = sprintf( + 'docker compose -f %s port wiremock 8080 2>&1', + escapeshellarg($dockerComposeFile) +); +exec($portCmd, $portOutput, $portExitCode); +if ($portExitCode === 0 && !empty($portOutput[0])) { + $parts = explode(':', $portOutput[0]); + $port = end($parts); + putenv("WIREMOCK_URL=http://localhost:{$port}"); + echo "WireMock container is ready on port {$port}\n"; +} else { + putenv('WIREMOCK_URL=http://localhost:8080'); + echo "WireMock container is ready (default port 8080)\n"; +} + +// Register shutdown function to stop the container after all tests complete +register_shutdown_function(function () use ($dockerComposeFile) { + echo "\nStopping WireMock container...\n"; + $cmd = sprintf( + 'docker compose -f %s down -v 2>&1', + escapeshellarg($dockerComposeFile) + ); + exec($cmd); +}); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/wiremock/docker-compose.test.yml b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/wiremock/docker-compose.test.yml new file mode 100644 index 000000000000..58747d54a46b --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/wiremock/docker-compose.test.yml @@ -0,0 +1,14 @@ +services: + wiremock: + image: wiremock/wiremock:3.9.1 + ports: + - "0:8080" # Use dynamic port to avoid conflicts with concurrent tests + volumes: + - ./wiremock-mappings.json:/home/wiremock/mappings/wiremock-mappings.json + command: ["--global-response-templating", "--verbose"] + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/__admin/health"] + interval: 2s + timeout: 5s + retries: 15 + start_period: 5s diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/wiremock/wiremock-mappings.json b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/wiremock/wiremock-mappings.json new file mode 100644 index 000000000000..8a3e7dd565af --- /dev/null +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/wiremock/wiremock-mappings.json @@ -0,0 +1,70 @@ +{ + "mappings": [ + { + "id": "ce59c023-78fc-4d8d-8e8c-95f5e1a6204a", + "name": "getWithBasicAuth - default", + "request": { + "urlPathTemplate": "/basic-auth", + "method": "GET", + "headers": { + "Authorization": { + "equalTo": "Basic dGVzdC11c2VybmFtZTo=" + } + } + }, + "response": { + "status": 200, + "body": "true", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "ce59c023-78fc-4d8d-8e8c-95f5e1a6204a", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + }, + "postServeActions": [] + }, + { + "id": "9cf6385c-29ea-4710-8792-fd2e00adc7c7", + "name": "postWithBasicAuth - default", + "request": { + "urlPathTemplate": "/basic-auth", + "method": "POST", + "headers": { + "Authorization": { + "equalTo": "Basic dGVzdC11c2VybmFtZTo=" + } + } + }, + "response": { + "status": 200, + "body": "true", + "headers": { + "Content-Type": "application/json" + } + }, + "uuid": "9cf6385c-29ea-4710-8792-fd2e00adc7c7", + "persistent": true, + "priority": 3, + "metadata": { + "mocklab": { + "created": { + "at": "2020-01-01T00:00:00.000Z", + "via": "SYSTEM" + } + } + } + } + ], + "meta": { + "total": 2 + } +} \ No newline at end of file diff --git a/seed/php-sdk/seed.yml b/seed/php-sdk/seed.yml index 42fa78a77e45..4a2eb1b196da 100644 --- a/seed/php-sdk/seed.yml +++ b/seed/php-sdk/seed.yml @@ -29,6 +29,10 @@ fixtures: - outputFolder: wire-tests customConfig: enable-wire-tests: true + basic-auth-pw-omitted: + - outputFolder: wire-tests + customConfig: + enable-wire-tests: true exhaustive: - outputFolder: no-custom-config customConfig: null From b29fcdd1cb167c6b9d86a9c598fd5d30aebc3642 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:47:30 +0000 Subject: [PATCH 22/24] fix(php-sdk): respect usernameOmit/passwordOmit in WireTestGenerator.buildAuthParamsForTest - WireTestGenerator now checks basicScheme.usernameOmit/passwordOmit before emitting auth params in setUp() - basic-auth-pw-omitted wire test no longer passes password to SeedClient constructor (which doesn't accept it) - Fixes Devin Review finding: unconditional password emission for omitted fields Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- generators/php/sdk/src/wire-tests/WireTestGenerator.ts | 10 +++++++--- .../wire-tests/tests/Wire/BasicAuthWireTest.php | 1 - 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/generators/php/sdk/src/wire-tests/WireTestGenerator.ts b/generators/php/sdk/src/wire-tests/WireTestGenerator.ts index 0beadd9f0892..9a2d1d0d5d60 100644 --- a/generators/php/sdk/src/wire-tests/WireTestGenerator.ts +++ b/generators/php/sdk/src/wire-tests/WireTestGenerator.ts @@ -489,9 +489,13 @@ export class WireTestGenerator { bearer: () => { authParams.push("token: 'test-token'"); }, - basic: () => { - authParams.push("username: 'test-user'"); - authParams.push("password: 'test-password'"); + basic: (basicScheme) => { + if (!basicScheme.usernameOmit) { + authParams.push("username: 'test-user'"); + } + if (!basicScheme.passwordOmit) { + authParams.push("password: 'test-password'"); + } }, header: (header) => { const paramName = this.case.camelSafe(header.name); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php index 2d804a182fff..67351729872a 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php @@ -62,7 +62,6 @@ protected function setUp(): void { $wiremockUrl = getenv('WIREMOCK_URL') ?: 'http://localhost:8080'; $this->client = new SeedClient( username: 'test-user', - password: 'test-password', options: [ 'baseUrl' => $wiremockUrl, ], From 19b38ac5448c1c8297de499a32081c153ce69e40 Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:14:35 +0000 Subject: [PATCH 23/24] chore(php-sdk): remove orphaned seed output for basic-auth-pw-omitted (pnpm seed clean) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../basic-auth-pw-omitted/.fern/metadata.json | 7 - .../.github/workflows/ci.yml | 52 - .../src/BasicAuth/BasicAuthClient.php | 146 --- .../src/Core/Client/BaseApiRequest.php | 22 - .../src/Core/Client/HttpClientBuilder.php | 56 - .../src/Core/Client/HttpMethod.php | 12 - .../src/Core/Client/MockHttpClient.php | 75 -- .../src/Core/Client/RawClient.php | 310 ----- .../src/Core/Client/RetryDecoratingClient.php | 241 ---- .../src/Core/Json/JsonApiRequest.php | 28 - .../src/Core/Json/JsonDecoder.php | 161 --- .../src/Core/Json/JsonDeserializer.php | 218 ---- .../src/Core/Json/JsonEncoder.php | 20 - .../src/Core/Json/JsonProperty.php | 13 - .../src/Core/Json/JsonSerializableType.php | 225 ---- .../src/Core/Json/JsonSerializer.php | 205 ---- .../src/Core/Json/Utils.php | 62 - .../Core/Multipart/MultipartApiRequest.php | 28 - .../src/Core/Multipart/MultipartFormData.php | 58 - .../Core/Multipart/MultipartFormDataPart.php | 62 - .../src/Core/Types/ArrayType.php | 16 - .../src/Core/Types/Constant.php | 12 - .../src/Core/Types/Date.php | 16 - .../src/Core/Types/Union.php | 62 - .../Types/UnauthorizedRequestErrorBody.php | 34 - .../src/Exceptions/SeedApiException.php | 53 - .../src/Exceptions/SeedException.php | 12 - .../basic-auth-pw-omitted/src/SeedClient.php | 67 - .../basic-auth-pw-omitted/src/Utils/File.php | 129 -- .../src/dynamic-snippets/example0/snippet.php | 13 - .../src/dynamic-snippets/example1/snippet.php | 13 - .../src/dynamic-snippets/example2/snippet.php | 13 - .../src/dynamic-snippets/example3/snippet.php | 17 - .../src/dynamic-snippets/example4/snippet.php | 17 - .../src/dynamic-snippets/example5/snippet.php | 17 - .../src/dynamic-snippets/example6/snippet.php | 17 - .../tests/Core/Client/RawClientTest.php | 1074 ----------------- .../Core/Json/AdditionalPropertiesTest.php | 76 -- .../tests/Core/Json/DateArrayTest.php | 54 - .../tests/Core/Json/EmptyArrayTest.php | 71 -- .../tests/Core/Json/EnumTest.php | 77 -- .../tests/Core/Json/ExhaustiveTest.php | 197 --- .../tests/Core/Json/InvalidTest.php | 42 - .../tests/Core/Json/NestedUnionArrayTest.php | 89 -- .../tests/Core/Json/NullPropertyTest.php | 53 - .../tests/Core/Json/NullableArrayTest.php | 49 - .../tests/Core/Json/ScalarTest.php | 116 -- .../tests/Core/Json/TraitTest.php | 60 - .../tests/Core/Json/UnionArrayTest.php | 57 - .../tests/Core/Json/UnionPropertyTest.php | 111 -- 50 files changed, 4635 deletions(-) delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/.fern/metadata.json delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/.github/workflows/ci.yml delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/BasicAuth/BasicAuthClient.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/BaseApiRequest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/HttpClientBuilder.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/HttpMethod.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/MockHttpClient.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RawClient.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RetryDecoratingClient.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonApiRequest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonEncoder.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonProperty.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializableType.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/Utils.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartApiRequest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormData.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormDataPart.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/ArrayType.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Constant.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Date.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Union.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Errors/Types/UnauthorizedRequestErrorBody.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedApiException.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedException.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/SeedClient.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/Utils/File.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Client/RawClientTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/AdditionalPropertiesTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/DateArrayTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EmptyArrayTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EnumTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ExhaustiveTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/InvalidTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NestedUnionArrayTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullPropertyTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullableArrayTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ScalarTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/TraitTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionArrayTest.php delete mode 100644 seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionPropertyTest.php diff --git a/seed/php-sdk/basic-auth-pw-omitted/.fern/metadata.json b/seed/php-sdk/basic-auth-pw-omitted/.fern/metadata.json deleted file mode 100644 index 143d7b896167..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/.fern/metadata.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "cliVersion": "DUMMY", - "generatorName": "fernapi/fern-php-sdk", - "generatorVersion": "local", - "originGitCommit": "DUMMY", - "sdkVersion": "0.0.1" -} \ No newline at end of file diff --git a/seed/php-sdk/basic-auth-pw-omitted/.github/workflows/ci.yml b/seed/php-sdk/basic-auth-pw-omitted/.github/workflows/ci.yml deleted file mode 100644 index 678eb6c9e141..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/.github/workflows/ci.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: ci - -on: [push] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: false - -jobs: - compile: - runs-on: ubuntu-latest - - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: "8.1" - - - name: Install tools - run: | - composer install - - - name: Build - run: | - composer build - - - name: Analyze - run: | - composer analyze - - test: - runs-on: ubuntu-latest - - steps: - - name: Checkout repo - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: "8.1" - - - name: Install tools - run: | - composer install - - - name: Run Tests - run: | - composer test diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/BasicAuth/BasicAuthClient.php b/seed/php-sdk/basic-auth-pw-omitted/src/BasicAuth/BasicAuthClient.php deleted file mode 100644 index ae6eddc5d2e0..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/BasicAuth/BasicAuthClient.php +++ /dev/null @@ -1,146 +0,0 @@ -, - * } $options @phpstan-ignore-next-line Property is used in endpoint methods via HttpEndpointGenerator - */ - private array $options; - - /** - * @var RawClient $client - */ - private RawClient $client; - - /** - * @param RawClient $client - * @param ?array{ - * baseUrl?: string, - * client?: ClientInterface, - * maxRetries?: int, - * timeout?: float, - * headers?: array, - * } $options - */ - public function __construct( - RawClient $client, - ?array $options = null, - ) { - $this->client = $client; - $this->options = $options ?? []; - } - - /** - * GET request with basic auth scheme - * - * @param ?array{ - * baseUrl?: string, - * maxRetries?: int, - * timeout?: float, - * headers?: array, - * queryParameters?: array, - * bodyProperties?: array, - * } $options - * @return ?bool - * @throws SeedException - * @throws SeedApiException - */ - public function getWithBasicAuth(?array $options = null): ?bool - { - $options = array_merge($this->options, $options ?? []); - try { - $response = $this->client->sendRequest( - new JsonApiRequest( - baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', - path: "basic-auth", - method: HttpMethod::GET, - ), - $options, - ); - $statusCode = $response->getStatusCode(); - if ($statusCode >= 200 && $statusCode < 400) { - $json = $response->getBody()->getContents(); - if (empty($json)) { - return null; - } - return JsonDecoder::decodeBool($json); - } - } catch (JsonException $e) { - throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); - } catch (ClientExceptionInterface $e) { - throw new SeedException(message: $e->getMessage(), previous: $e); - } - throw new SeedApiException( - message: 'API request failed', - statusCode: $statusCode, - body: $response->getBody()->getContents(), - ); - } - - /** - * POST request with basic auth scheme - * - * @param mixed $request - * @param ?array{ - * baseUrl?: string, - * maxRetries?: int, - * timeout?: float, - * headers?: array, - * queryParameters?: array, - * bodyProperties?: array, - * } $options - * @return ?bool - * @throws SeedException - * @throws SeedApiException - */ - public function postWithBasicAuth(mixed $request, ?array $options = null): ?bool - { - $options = array_merge($this->options, $options ?? []); - try { - $response = $this->client->sendRequest( - new JsonApiRequest( - baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? '', - path: "basic-auth", - method: HttpMethod::POST, - body: $request, - ), - $options, - ); - $statusCode = $response->getStatusCode(); - if ($statusCode >= 200 && $statusCode < 400) { - $json = $response->getBody()->getContents(); - if (empty($json)) { - return null; - } - return JsonDecoder::decodeBool($json); - } - } catch (JsonException $e) { - throw new SeedException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); - } catch (ClientExceptionInterface $e) { - throw new SeedException(message: $e->getMessage(), previous: $e); - } - throw new SeedApiException( - message: 'API request failed', - statusCode: $statusCode, - body: $response->getBody()->getContents(), - ); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/BaseApiRequest.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/BaseApiRequest.php deleted file mode 100644 index 5e1283e2b6f6..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/BaseApiRequest.php +++ /dev/null @@ -1,22 +0,0 @@ - $headers Additional headers for the request (optional) - * @param array $query Query parameters for the request (optional) - */ - public function __construct( - public readonly string $baseUrl, - public readonly string $path, - public readonly HttpMethod $method, - public readonly array $headers = [], - public readonly array $query = [], - ) { - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/HttpClientBuilder.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/HttpClientBuilder.php deleted file mode 100644 index 8ac806af0325..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/HttpClientBuilder.php +++ /dev/null @@ -1,56 +0,0 @@ - - */ - private array $responses = []; - - /** - * @var array - */ - private array $requests = []; - - /** - * @param ResponseInterface ...$responses - */ - public function append(ResponseInterface ...$responses): void - { - foreach ($responses as $response) { - $this->responses[] = $response; - } - } - - /** - * @param RequestInterface $request - * @return ResponseInterface - */ - public function sendRequest(RequestInterface $request): ResponseInterface - { - $this->requests[] = $request; - - if (empty($this->responses)) { - throw new RuntimeException('No more responses in the queue. Add responses using append().'); - } - - return array_shift($this->responses); - } - - /** - * @return ?RequestInterface - */ - public function getLastRequest(): ?RequestInterface - { - if (empty($this->requests)) { - return null; - } - return $this->requests[count($this->requests) - 1]; - } - - /** - * @return int - */ - public function getRequestCount(): int - { - return count($this->requests); - } - - /** - * Returns the number of remaining responses in the queue. - * - * @return int - */ - public function count(): int - { - return count($this->responses); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RawClient.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RawClient.php deleted file mode 100644 index 14716c7d678b..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RawClient.php +++ /dev/null @@ -1,310 +0,0 @@ - $headers - */ - private array $headers; - - /** - * @var ?(callable(): array) $getAuthHeaders - */ - private $getAuthHeaders; - - /** - * @param ?array{ - * baseUrl?: string, - * client?: ClientInterface, - * maxRetries?: int, - * timeout?: float, - * headers?: array, - * getAuthHeaders?: callable(): array, - * } $options - */ - public function __construct( - public readonly ?array $options = null, - ) { - $this->client = HttpClientBuilder::build( - $this->options['client'] ?? null, - $this->options['maxRetries'] ?? 2, - ); - $this->requestFactory = HttpClientBuilder::requestFactory(); - $this->streamFactory = HttpClientBuilder::streamFactory(); - $this->headers = $this->options['headers'] ?? []; - $this->getAuthHeaders = $this->options['getAuthHeaders'] ?? null; - } - - /** - * @param BaseApiRequest $request - * @param ?array{ - * maxRetries?: int, - * timeout?: float, - * headers?: array, - * queryParameters?: array, - * bodyProperties?: array, - * } $options - * @return ResponseInterface - * @throws ClientExceptionInterface - */ - public function sendRequest( - BaseApiRequest $request, - ?array $options = null, - ): ResponseInterface { - $opts = $options ?? []; - $httpRequest = $this->buildRequest($request, $opts); - - $timeout = $opts['timeout'] ?? $this->options['timeout'] ?? null; - $maxRetries = $opts['maxRetries'] ?? null; - - return $this->client->send($httpRequest, $timeout, $maxRetries); - } - - /** - * @param BaseApiRequest $request - * @param array{ - * headers?: array, - * queryParameters?: array, - * bodyProperties?: array, - * } $options - * @return RequestInterface - */ - private function buildRequest( - BaseApiRequest $request, - array $options - ): RequestInterface { - $url = $this->buildUrl($request, $options); - $headers = $this->encodeHeaders($request, $options); - - $httpRequest = $this->requestFactory->createRequest( - $request->method->name, - $url, - ); - - // Encode body and, for multipart, capture the Content-Type with boundary. - if ($request instanceof MultipartApiRequest && $request->body !== null) { - $builder = new MultipartStreamBuilder($this->streamFactory); - $request->body->addToBuilder($builder); - $httpRequest = $httpRequest->withBody($builder->build()); - $headers['Content-Type'] = "multipart/form-data; boundary={$builder->getBoundary()}"; - } else { - $body = $this->encodeRequestBody($request, $options); - if ($body !== null) { - $httpRequest = $httpRequest->withBody($body); - } - } - - foreach ($headers as $name => $value) { - $httpRequest = $httpRequest->withHeader($name, $value); - } - - return $httpRequest; - } - - /** - * @param BaseApiRequest $request - * @param array{ - * headers?: array, - * } $options - * @return array - */ - private function encodeHeaders( - BaseApiRequest $request, - array $options, - ): array { - $authHeaders = $this->getAuthHeaders !== null ? ($this->getAuthHeaders)() : []; - return match (get_class($request)) { - JsonApiRequest::class => array_merge( - [ - "Content-Type" => "application/json", - "Accept" => "*/*", - ], - $this->headers, - $authHeaders, - $request->headers, - $options['headers'] ?? [], - ), - MultipartApiRequest::class => array_merge( - $this->headers, - $authHeaders, - $request->headers, - $options['headers'] ?? [], - ), - default => throw new InvalidArgumentException('Unsupported request type: ' . get_class($request)), - }; - } - - /** - * @param BaseApiRequest $request - * @param array{ - * bodyProperties?: array, - * } $options - * @return ?StreamInterface - */ - private function encodeRequestBody( - BaseApiRequest $request, - array $options, - ): ?StreamInterface { - if ($request instanceof JsonApiRequest) { - return $request->body === null ? null : $this->streamFactory->createStream( - JsonEncoder::encode( - $this->buildJsonBody( - $request->body, - $options, - ), - ) - ); - } - - if ($request instanceof MultipartApiRequest) { - return null; - } - - throw new InvalidArgumentException('Unsupported request type: ' . get_class($request)); - } - - /** - * @param mixed $body - * @param array{ - * bodyProperties?: array, - * } $options - * @return mixed - */ - private function buildJsonBody( - mixed $body, - array $options, - ): mixed { - $overrideProperties = $options['bodyProperties'] ?? []; - if (is_array($body) && (empty($body) || self::isSequential($body))) { - return array_merge($body, $overrideProperties); - } - - if ($body instanceof JsonSerializable) { - $result = $body->jsonSerialize(); - } else { - $result = $body; - } - if (is_array($result)) { - $result = array_merge($result, $overrideProperties); - if (empty($result)) { - // force to be serialized as {} instead of [] - return (object)($result); - } - } - - return $result; - } - - /** - * @param BaseApiRequest $request - * @param array{ - * queryParameters?: array, - * } $options - * @return string - */ - private function buildUrl( - BaseApiRequest $request, - array $options, - ): string { - $baseUrl = $request->baseUrl; - $trimmedBaseUrl = rtrim($baseUrl, '/'); - $trimmedBasePath = ltrim($request->path, '/'); - $url = "{$trimmedBaseUrl}/{$trimmedBasePath}"; - $query = array_merge( - $request->query, - $options['queryParameters'] ?? [], - ); - if (!empty($query)) { - $url .= '?' . $this->encodeQuery($query); - } - return $url; - } - - /** - * @param array $query - * @return string - */ - private function encodeQuery(array $query): string - { - $parts = []; - foreach ($query as $key => $value) { - if (is_array($value)) { - foreach ($value as $item) { - $parts[] = urlencode($key) . '=' . $this->encodeQueryValue($item); - } - } else { - $parts[] = urlencode($key) . '=' . $this->encodeQueryValue($value); - } - } - return implode('&', $parts); - } - - private function encodeQueryValue(mixed $value): string - { - if (is_string($value)) { - return urlencode($value); - } - if (is_bool($value)) { - return $value ? 'true' : 'false'; - } - if (is_scalar($value)) { - return urlencode((string)$value); - } - if (is_null($value)) { - return 'null'; - } - // Unreachable, but included for a best effort. - return urlencode(JsonEncoder::encode($value)); - } - - /** - * Check if an array is sequential, not associative. - * @param mixed[] $arr - * @return bool - */ - private static function isSequential(array $arr): bool - { - if (empty($arr)) { - return false; - } - $length = count($arr); - $keys = array_keys($arr); - for ($i = 0; $i < $length; $i++) { - if ($keys[$i] !== $i) { - return false; - } - } - return true; - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RetryDecoratingClient.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RetryDecoratingClient.php deleted file mode 100644 index b16170cf2805..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Client/RetryDecoratingClient.php +++ /dev/null @@ -1,241 +0,0 @@ -client = $client; - $this->maxRetries = $maxRetries; - $this->baseDelay = $baseDelay; - $this->sleepFunction = $sleepFunction ?? 'usleep'; - } - - /** - * @param RequestInterface $request - * @return ResponseInterface - * @throws ClientExceptionInterface - */ - public function sendRequest(RequestInterface $request): ResponseInterface - { - return $this->send($request); - } - - /** - * Sends a request with optional per-request timeout and retry overrides. - * - * When a Guzzle or Symfony PSR-18 client is detected, the timeout is - * forwarded via the client's native API. For other PSR-18 clients the - * timeout value is silently ignored. - * - * @param RequestInterface $request - * @param ?float $timeout Timeout in seconds, or null to use the client default. - * @param ?int $maxRetries Maximum retry attempts, or null to use the client default. - * @return ResponseInterface - * @throws ClientExceptionInterface - */ - public function send( - RequestInterface $request, - ?float $timeout = null, - ?int $maxRetries = null, - ): ResponseInterface { - $maxRetries = $maxRetries ?? $this->maxRetries; - $retryAttempt = 0; - $lastResponse = null; - - while (true) { - try { - $lastResponse = $this->doSend($request, $timeout); - if (!$this->shouldRetry($retryAttempt, $maxRetries, $lastResponse)) { - return $lastResponse; - } - } catch (ClientExceptionInterface $e) { - if ($retryAttempt >= $maxRetries) { - throw $e; - } - } - - $retryAttempt++; - $delay = $this->getRetryDelay($retryAttempt, $lastResponse); - ($this->sleepFunction)($delay * 1000); // Convert milliseconds to microseconds - - // Rewind the request body so retries don't send an empty body. - $request->getBody()->rewind(); - } - } - - /** - * Dispatches the request to the underlying client, forwarding the timeout - * option to Guzzle or Symfony when available. - * - * @param RequestInterface $request - * @param ?float $timeout - * @return ResponseInterface - * @throws ClientExceptionInterface - */ - private function doSend(RequestInterface $request, ?float $timeout): ResponseInterface - { - static $warned = false; - - if ($timeout === null) { - return $this->client->sendRequest($request); - } - - if (class_exists('GuzzleHttp\ClientInterface') - && $this->client instanceof \GuzzleHttp\ClientInterface - ) { - return $this->client->send($request, ['timeout' => $timeout]); - } - if (class_exists('Symfony\Component\HttpClient\Psr18Client') - && $this->client instanceof \Symfony\Component\HttpClient\Psr18Client - ) { - /** @var ClientInterface $clientWithTimeout */ - $clientWithTimeout = $this->client->withOptions(['timeout' => $timeout]); - return $clientWithTimeout->sendRequest($request); - } - - if ($warned) { - return $this->client->sendRequest($request); - } - $warned = true; - trigger_error( - 'Timeout option is not supported for the current PSR-18 client (' - . get_class($this->client) - . '). Use Guzzle or Symfony HttpClient for timeout support.', - E_USER_WARNING, - ); - return $this->client->sendRequest($request); - } - - /** - * @param int $retryAttempt - * @param int $maxRetries - * @param ?ResponseInterface $response - * @return bool - */ - private function shouldRetry( - int $retryAttempt, - int $maxRetries, - ?ResponseInterface $response = null, - ): bool { - if ($retryAttempt >= $maxRetries) { - return false; - } - - if ($response !== null) { - return $response->getStatusCode() >= 500 || - in_array($response->getStatusCode(), self::RETRY_STATUS_CODES); - } - - return false; - } - - /** - * Calculate the retry delay based on response headers or exponential backoff. - * - * @param int $retryAttempt - * @param ?ResponseInterface $response - * @return int milliseconds - */ - private function getRetryDelay(int $retryAttempt, ?ResponseInterface $response): int - { - if ($response !== null) { - // Check Retry-After header - $retryAfter = $response->getHeaderLine('Retry-After'); - if ($retryAfter !== '') { - // Try parsing as integer (seconds) - if (is_numeric($retryAfter)) { - $retryAfterSeconds = (int)$retryAfter; - if ($retryAfterSeconds > 0) { - return min($retryAfterSeconds * 1000, self::MAX_RETRY_DELAY); - } - } - - // Try parsing as HTTP date - $retryAfterDate = strtotime($retryAfter); - if ($retryAfterDate !== false) { - $delay = ($retryAfterDate - time()) * 1000; - if ($delay > 0) { - return min(max($delay, 0), self::MAX_RETRY_DELAY); - } - } - } - - // Check X-RateLimit-Reset header - $rateLimitReset = $response->getHeaderLine('X-RateLimit-Reset'); - if ($rateLimitReset !== '' && is_numeric($rateLimitReset)) { - $resetTime = (int)$rateLimitReset; - $delay = ($resetTime * 1000) - (int)(microtime(true) * 1000); - if ($delay > 0) { - return $this->addPositiveJitter(min($delay, self::MAX_RETRY_DELAY)); - } - } - } - - // Fall back to exponential backoff with symmetric jitter - return $this->addSymmetricJitter( - min($this->exponentialDelay($retryAttempt), self::MAX_RETRY_DELAY) - ); - } - - /** - * Add positive jitter (0% to +20%) to the delay. - * - * @param int $delay - * @return int - */ - private function addPositiveJitter(int $delay): int - { - $jitterMultiplier = 1 + (mt_rand() / mt_getrandmax()) * self::JITTER_FACTOR; - return (int)($delay * $jitterMultiplier); - } - - /** - * Add symmetric jitter (-10% to +10%) to the delay. - * - * @param int $delay - * @return int - */ - private function addSymmetricJitter(int $delay): int - { - $jitterMultiplier = 1 + ((mt_rand() / mt_getrandmax()) - 0.5) * self::JITTER_FACTOR; - return (int)($delay * $jitterMultiplier); - } - - /** - * Default exponential backoff delay function. - * - * @return int milliseconds. - */ - private function exponentialDelay(int $retryAttempt): int - { - return 2 ** ($retryAttempt - 1) * $this->baseDelay; - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonApiRequest.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonApiRequest.php deleted file mode 100644 index 8fdf493606e6..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonApiRequest.php +++ /dev/null @@ -1,28 +0,0 @@ - $headers Additional headers for the request (optional) - * @param array $query Query parameters for the request (optional) - * @param mixed|null $body The JSON request body (optional) - */ - public function __construct( - string $baseUrl, - string $path, - HttpMethod $method, - array $headers = [], - array $query = [], - public readonly mixed $body = null - ) { - parent::__construct($baseUrl, $path, $method, $headers, $query); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php deleted file mode 100644 index 2da34087c644..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDecoder.php +++ /dev/null @@ -1,161 +0,0 @@ - $type The type definition for deserialization. - * @return mixed[]|array The deserialized array. - * @throws JsonException If the decoded value is not an array. - */ - public static function decodeArray(string $json, array $type): array - { - $decoded = self::decode($json); - if (!is_array($decoded)) { - throw new JsonException("Unexpected non-array json value: $json"); - } - return JsonDeserializer::deserializeArray($decoded, $type); - } - - /** - * Decodes a JSON string and deserializes it based on the provided union type definition. - * - * @param string $json The JSON string to decode. - * @param Union $union The union type definition for deserialization. - * @return mixed The deserialized value. - * @throws JsonException If the deserialization for all types in the union fails. - */ - public static function decodeUnion(string $json, Union $union): mixed - { - $decoded = self::decode($json); - return JsonDeserializer::deserializeUnion($decoded, $union); - } - /** - * Decodes a JSON string and returns a mixed. - * - * @param string $json The JSON string to decode. - * @return mixed The decoded mixed. - * @throws JsonException If the decoded value is not an mixed. - */ - public static function decodeMixed(string $json): mixed - { - return self::decode($json); - } - - /** - * Decodes a JSON string into a PHP value. - * - * @param string $json The JSON string to decode. - * @return mixed The decoded value. - * @throws JsonException If an error occurs during JSON decoding. - */ - public static function decode(string $json): mixed - { - return json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php deleted file mode 100644 index 1a250c614e45..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonDeserializer.php +++ /dev/null @@ -1,218 +0,0 @@ - $data The array to be deserialized. - * @param array $type The type definition from the annotation. - * @return array The deserialized array. - * @throws JsonException If deserialization fails. - */ - public static function deserializeArray(array $data, array $type): array - { - return Utils::isMapType($type) - ? self::deserializeMap($data, $type) - : self::deserializeList($data, $type); - } - - /** - * Deserializes a value based on its type definition. - * - * @param mixed $data The data to deserialize. - * @param mixed $type The type definition. - * @return mixed The deserialized value. - * @throws JsonException If deserialization fails. - */ - private static function deserializeValue(mixed $data, mixed $type): mixed - { - if ($type instanceof Union) { - return self::deserializeUnion($data, $type); - } - - if (is_array($type)) { - return self::deserializeArray((array)$data, $type); - } - - if (gettype($type) !== "string") { - throw new JsonException("Unexpected non-string type."); - } - - return self::deserializeSingleValue($data, $type); - } - - /** - * Deserializes a value based on the possible types in a union type definition. - * - * @param mixed $data The data to deserialize. - * @param Union $type The union type definition. - * @return mixed The deserialized value. - * @throws JsonException If none of the union types can successfully deserialize the value. - */ - public static function deserializeUnion(mixed $data, Union $type): mixed - { - foreach ($type->types as $unionType) { - try { - return self::deserializeValue($data, $unionType); - } catch (\Throwable) { - // Catching Throwable instead of Exception to handle TypeError - // that occurs when assigning null to non-nullable typed properties - continue; - } - } - $readableType = Utils::getReadableType($data); - throw new JsonException( - "Cannot deserialize value of type $readableType with any of the union types: $type" - ); - } - - /** - * Deserializes a single value based on its expected type. - * - * @param mixed $data The data to deserialize. - * @param string $type The expected type. - * @return mixed The deserialized value. - * @throws JsonException If deserialization fails. - */ - private static function deserializeSingleValue(mixed $data, string $type): mixed - { - if ($type === 'null' && $data === null) { - return null; - } - - if ($type === 'date' && is_string($data)) { - return self::deserializeDate($data); - } - - if ($type === 'datetime' && is_string($data)) { - return self::deserializeDateTime($data); - } - - if ($type === 'mixed') { - return $data; - } - - if (class_exists($type) && is_array($data)) { - /** @var array $data */ - return self::deserializeObject($data, $type); - } - - // Handle floats as a special case since gettype($data) returns "double" for float values in PHP, and because - // floats make come through from json_decoded as integers - if ($type === 'float' && (is_numeric($data))) { - return (float) $data; - } - - // Handle bools as a special case since gettype($data) returns "boolean" for bool values in PHP. - if ($type === 'bool' && is_bool($data)) { - return $data; - } - - if (gettype($data) === $type) { - return $data; - } - - throw new JsonException("Unable to deserialize value of type '" . gettype($data) . "' as '$type'."); - } - - /** - * Deserializes an array into an object of the given type. - * - * @param array $data The data to deserialize. - * @param string $type The class name of the object to deserialize into. - * - * @return object The deserialized object. - * - * @throws JsonException If the type does not implement JsonSerializableType. - */ - public static function deserializeObject(array $data, string $type): object - { - if (!is_subclass_of($type, JsonSerializableType::class)) { - throw new JsonException("$type is not a subclass of JsonSerializableType."); - } - return $type::jsonDeserialize($data); - } - - /** - * Deserializes a map (associative array) with defined key and value types. - * - * @param array $data The associative array to deserialize. - * @param array $type The type definition for the map. - * @return array The deserialized map. - * @throws JsonException If deserialization fails. - */ - private static function deserializeMap(array $data, array $type): array - { - $keyType = array_key_first($type); - if ($keyType === null) { - throw new JsonException("Unexpected no key in ArrayType."); - } - $keyType = (string) $keyType; - $valueType = $type[$keyType]; - /** @var array $result */ - $result = []; - - foreach ($data as $key => $item) { - $key = (string) Utils::castKey($key, $keyType); - $result[$key] = self::deserializeValue($item, $valueType); - } - - return $result; - } - - /** - * Deserializes a list (indexed array) with a defined value type. - * - * @param array $data The list to deserialize. - * @param array $type The type definition for the list. - * @return array The deserialized list. - * @throws JsonException If deserialization fails. - */ - private static function deserializeList(array $data, array $type): array - { - $valueType = $type[0]; - /** @var array */ - return array_map(fn ($item) => self::deserializeValue($item, $valueType), $data); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonEncoder.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonEncoder.php deleted file mode 100644 index 0dbf3fcc9948..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonEncoder.php +++ /dev/null @@ -1,20 +0,0 @@ - Extra properties from JSON that don't map to class properties */ - private array $__additionalProperties = []; - - /** @var array Properties that have been explicitly set via setter methods */ - private array $__explicitlySetProperties = []; - - /** - * Serializes the object to a JSON string. - * - * @return string JSON-encoded string representation of the object. - * @throws Exception If encoding fails. - */ - public function toJson(): string - { - $serializedObject = $this->jsonSerialize(); - $encoded = JsonEncoder::encode($serializedObject); - if (!$encoded) { - throw new Exception("Could not encode type"); - } - return $encoded; - } - - /** - * Serializes the object to an array. - * - * @return mixed[] Array representation of the object. - * @throws JsonException If serialization fails. - */ - public function jsonSerialize(): array - { - $result = []; - $reflectionClass = new \ReflectionClass($this); - foreach ($reflectionClass->getProperties() as $property) { - $jsonKey = self::getJsonKey($property); - if ($jsonKey === null) { - continue; - } - $value = $property->getValue($this); - - // Handle DateTime properties - $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; - if ($dateTypeAttr && $value instanceof DateTime) { - $dateType = $dateTypeAttr->newInstance()->type; - $value = ($dateType === Date::TYPE_DATE) - ? JsonSerializer::serializeDate($value) - : JsonSerializer::serializeDateTime($value); - } - - // Handle Union annotations - $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; - if ($unionTypeAttr) { - $unionType = $unionTypeAttr->newInstance(); - $value = JsonSerializer::serializeUnion($value, $unionType); - } - - // Handle arrays with type annotations - $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; - if ($arrayTypeAttr && is_array($value)) { - $arrayType = $arrayTypeAttr->newInstance()->type; - $value = JsonSerializer::serializeArray($value, $arrayType); - } - - // Handle object - if (is_object($value)) { - $value = JsonSerializer::serializeObject($value); - } - - // Include the value if it's not null, OR if it was explicitly set (even to null) - if ($value !== null || array_key_exists($property->getName(), $this->__explicitlySetProperties)) { - $result[$jsonKey] = $value; - } - } - return $result; - } - - /** - * Deserializes a JSON string into an instance of the calling class. - * - * @param string $json JSON string to deserialize. - * @return static Deserialized object. - * @throws JsonException If decoding fails or the result is not an array. - * @throws Exception If deserialization fails. - */ - public static function fromJson(string $json): static - { - $decodedJson = JsonDecoder::decode($json); - if (!is_array($decodedJson)) { - throw new JsonException("Unexpected non-array decoded type: " . gettype($decodedJson)); - } - /** @var array $decodedJson */ - return self::jsonDeserialize($decodedJson); - } - - /** - * Deserializes an array into an instance of the calling class. - * - * @param array $data Array data to deserialize. - * @return static Deserialized object. - * @throws JsonException If deserialization fails. - */ - public static function jsonDeserialize(array $data): static - { - $reflectionClass = new \ReflectionClass(static::class); - $constructor = $reflectionClass->getConstructor(); - if ($constructor === null) { - throw new JsonException("No constructor found."); - } - - $args = []; - $properties = []; - $additionalProperties = []; - foreach ($reflectionClass->getProperties() as $property) { - $jsonKey = self::getJsonKey($property) ?? $property->getName(); - $properties[$jsonKey] = $property; - } - - foreach ($data as $jsonKey => $value) { - if (!isset($properties[$jsonKey])) { - // This JSON key doesn't map to any class property - add it to additionalProperties - $additionalProperties[$jsonKey] = $value; - continue; - } - - $property = $properties[$jsonKey]; - - // Handle Date annotation - $dateTypeAttr = $property->getAttributes(Date::class)[0] ?? null; - if ($dateTypeAttr) { - $dateType = $dateTypeAttr->newInstance()->type; - if (!is_string($value)) { - throw new JsonException("Unexpected non-string type for date."); - } - $value = ($dateType === Date::TYPE_DATE) - ? JsonDeserializer::deserializeDate($value) - : JsonDeserializer::deserializeDateTime($value); - } - - // Handle Array annotation - $arrayTypeAttr = $property->getAttributes(ArrayType::class)[0] ?? null; - if (is_array($value) && $arrayTypeAttr) { - $arrayType = $arrayTypeAttr->newInstance()->type; - $value = JsonDeserializer::deserializeArray($value, $arrayType); - } - - // Handle Union annotations - $unionTypeAttr = $property->getAttributes(Union::class)[0] ?? null; - if ($unionTypeAttr) { - $unionType = $unionTypeAttr->newInstance(); - $value = JsonDeserializer::deserializeUnion($value, $unionType); - } - - // Handle object - $type = $property->getType(); - if (is_array($value) && $type instanceof ReflectionNamedType && !$type->isBuiltin()) { - /** @var array $arrayValue */ - $arrayValue = $value; - $value = JsonDeserializer::deserializeObject($arrayValue, $type->getName()); - } - - $args[$property->getName()] = $value; - } - - // Fill in any missing properties with defaults - foreach ($properties as $property) { - if (!isset($args[$property->getName()])) { - $args[$property->getName()] = $property->hasDefaultValue() ? $property->getDefaultValue() : null; - } - } - - // @phpstan-ignore-next-line - $result = new static($args); - $result->__additionalProperties = $additionalProperties; - return $result; - } - - /** - * Get properties from JSON that weren't mapped to class fields - * @return array - */ - public function getAdditionalProperties(): array - { - return $this->__additionalProperties; - } - - /** - * Mark a property as explicitly set. - * This ensures the property will be included in JSON serialization even if null. - * - * @param string $propertyName The name of the property to mark as explicitly set. - */ - protected function _setField(string $propertyName): void - { - $this->__explicitlySetProperties[$propertyName] = true; - } - - /** - * Retrieves the JSON key associated with a property. - * - * @param ReflectionProperty $property The reflection property. - * @return ?string The JSON key, or null if not available. - */ - private static function getJsonKey(ReflectionProperty $property): ?string - { - $jsonPropertyAttr = $property->getAttributes(JsonProperty::class)[0] ?? null; - return $jsonPropertyAttr?->newInstance()?->name; - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php deleted file mode 100644 index f7d80ed5e8f3..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/JsonSerializer.php +++ /dev/null @@ -1,205 +0,0 @@ -format(Constant::DateFormat); - } - - /** - * Serializes a DateTime object into a string using the date-time format. - * Normalizes UTC times to use 'Z' suffix instead of '+00:00'. - * - * @param DateTime $date The DateTime object to serialize. - * @return string The serialized date-time string. - */ - public static function serializeDateTime(DateTime $date): string - { - $formatted = $date->format(Constant::DateTimeFormat); - if (str_ends_with($formatted, '+00:00')) { - return substr($formatted, 0, -6) . 'Z'; - } - return $formatted; - } - - /** - * Serializes an array based on type annotations (either a list or map). - * - * @param array $data The array to be serialized. - * @param array $type The type definition from the annotation. - * @return array The serialized array. - * @throws JsonException If serialization fails. - */ - public static function serializeArray(array $data, array $type): array - { - return Utils::isMapType($type) - ? self::serializeMap($data, $type) - : self::serializeList($data, $type); - } - - /** - * Serializes a value based on its type definition. - * - * @param mixed $data The value to serialize. - * @param mixed $type The type definition. - * @return mixed The serialized value. - * @throws JsonException If serialization fails. - */ - private static function serializeValue(mixed $data, mixed $type): mixed - { - if ($type instanceof Union) { - return self::serializeUnion($data, $type); - } - - if (is_array($type)) { - return self::serializeArray((array)$data, $type); - } - - if (gettype($type) !== "string") { - throw new JsonException("Unexpected non-string type."); - } - - return self::serializeSingleValue($data, $type); - } - - /** - * Serializes a value for a union type definition. - * - * @param mixed $data The value to serialize. - * @param Union $unionType The union type definition. - * @return mixed The serialized value. - * @throws JsonException If serialization fails for all union types. - */ - public static function serializeUnion(mixed $data, Union $unionType): mixed - { - foreach ($unionType->types as $type) { - try { - return self::serializeValue($data, $type); - } catch (Exception) { - // Try the next type in the union - continue; - } - } - $readableType = Utils::getReadableType($data); - throw new JsonException( - "Cannot serialize value of type $readableType with any of the union types: $unionType" - ); - } - - /** - * Serializes a single value based on its type. - * - * @param mixed $data The value to serialize. - * @param string $type The expected type. - * @return mixed The serialized value. - * @throws JsonException If serialization fails. - */ - private static function serializeSingleValue(mixed $data, string $type): mixed - { - if ($type === 'null' && $data === null) { - return null; - } - - if (($type === 'date' || $type === 'datetime') && $data instanceof DateTime) { - return $type === 'date' ? self::serializeDate($data) : self::serializeDateTime($data); - } - - if ($type === 'mixed') { - return $data; - } - - if (class_exists($type) && $data instanceof $type) { - return self::serializeObject($data); - } - - // Handle floats as a special case since gettype($data) returns "double" for float values in PHP. - if ($type === 'float' && is_float($data)) { - return $data; - } - - // Handle bools as a special case since gettype($data) returns "boolean" for bool values in PHP. - if ($type === 'bool' && is_bool($data)) { - return $data; - } - - if (gettype($data) === $type) { - return $data; - } - - throw new JsonException("Unable to serialize value of type '" . gettype($data) . "' as '$type'."); - } - - /** - * Serializes an object to a JSON-serializable format. - * - * @param object $data The object to serialize. - * @return mixed The serialized data. - * @throws JsonException If the object does not implement JsonSerializable. - */ - public static function serializeObject(object $data): mixed - { - if (!is_subclass_of($data, JsonSerializable::class)) { - $type = get_class($data); - throw new JsonException("Class $type must implement JsonSerializable."); - } - return $data->jsonSerialize(); - } - - /** - * Serializes a map (associative array) with defined key and value types. - * - * @param array $data The associative array to serialize. - * @param array $type The type definition for the map. - * @return array The serialized map. - * @throws JsonException If serialization fails. - */ - private static function serializeMap(array $data, array $type): array - { - $keyType = array_key_first($type); - if ($keyType === null) { - throw new JsonException("Unexpected no key in ArrayType."); - } - $keyType = (string) $keyType; - $valueType = $type[$keyType]; - /** @var array $result */ - $result = []; - - foreach ($data as $key => $item) { - $key = (string) Utils::castKey($key, $keyType); - $result[$key] = self::serializeValue($item, $valueType); - } - - return $result; - } - - /** - * Serializes a list (indexed array) where only the value type is defined. - * - * @param array $data The list to serialize. - * @param array $type The type definition for the list. - * @return array The serialized list. - * @throws JsonException If serialization fails. - */ - private static function serializeList(array $data, array $type): array - { - $valueType = $type[0]; - /** @var array */ - return array_map(fn ($item) => self::serializeValue($item, $valueType), $data); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/Utils.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/Utils.php deleted file mode 100644 index 4099b8253005..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Json/Utils.php +++ /dev/null @@ -1,62 +0,0 @@ - $type The type definition from the annotation. - * @return bool True if the type is a map, false if it's a list. - */ - public static function isMapType(array $type): bool - { - return count($type) === 1 && !array_is_list($type); - } - - /** - * Casts the key to the appropriate type based on the key type. - * - * @param mixed $key The key to be cast. - * @param string $keyType The type to cast the key to ('string', 'integer', 'float'). - * @return int|string The casted key. - * @throws JsonException - */ - public static function castKey(mixed $key, string $keyType): int|string - { - if (!is_scalar($key)) { - throw new JsonException("Key must be a scalar type."); - } - return match ($keyType) { - 'integer' => (int)$key, - // PHP arrays don't support float keys; truncate to int - 'float' => (int)$key, - 'string' => (string)$key, - default => is_int($key) ? $key : (string)$key, - }; - } - - /** - * Returns a human-readable representation of the input's type. - * - * @param mixed $input The input value to determine the type of. - * @return string A readable description of the input type. - */ - public static function getReadableType(mixed $input): string - { - if (is_object($input)) { - return get_class($input); - } elseif (is_array($input)) { - return 'array(' . count($input) . ' items)'; - } elseif (is_null($input)) { - return 'null'; - } else { - return gettype($input); - } - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartApiRequest.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartApiRequest.php deleted file mode 100644 index 7760366456c8..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartApiRequest.php +++ /dev/null @@ -1,28 +0,0 @@ - $headers Additional headers for the request (optional) - * @param array $query Query parameters for the request (optional) - * @param ?MultipartFormData $body The multipart form data for the request (optional) - */ - public function __construct( - string $baseUrl, - string $path, - HttpMethod $method, - array $headers = [], - array $query = [], - public readonly ?MultipartFormData $body = null - ) { - parent::__construct($baseUrl, $path, $method, $headers, $query); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormData.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormData.php deleted file mode 100644 index 911a28b6ad64..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormData.php +++ /dev/null @@ -1,58 +0,0 @@ - - */ - private array $parts = []; - - /** - * Adds a new part to the multipart form data. - * - * @param string $name - * @param string|int|bool|float|StreamInterface $value - * @param ?string $contentType - */ - public function add( - string $name, - string|int|bool|float|StreamInterface $value, - ?string $contentType = null, - ): void { - $headers = $contentType !== null ? ['Content-Type' => $contentType] : null; - $this->addPart( - new MultipartFormDataPart( - name: $name, - value: $value, - headers: $headers, - ) - ); - } - - /** - * Adds a new part to the multipart form data. - * - * @param MultipartFormDataPart $part - */ - public function addPart(MultipartFormDataPart $part): void - { - $this->parts[] = $part; - } - - /** - * Adds all parts to a MultipartStreamBuilder. - * - * @param MultipartStreamBuilder $builder - */ - public function addToBuilder(MultipartStreamBuilder $builder): void - { - foreach ($this->parts as $part) { - $part->addToBuilder($builder); - } - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormDataPart.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormDataPart.php deleted file mode 100644 index 4db35e58ae37..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Multipart/MultipartFormDataPart.php +++ /dev/null @@ -1,62 +0,0 @@ - - */ - private ?array $headers; - - /** - * @param string $name - * @param string|bool|float|int|StreamInterface $value - * @param ?string $filename - * @param ?array $headers - */ - public function __construct( - string $name, - string|bool|float|int|StreamInterface $value, - ?string $filename = null, - ?array $headers = null - ) { - $this->name = $name; - $this->contents = $value instanceof StreamInterface ? $value : (string)$value; - $this->filename = $filename; - $this->headers = $headers; - } - - /** - * Adds this part to a MultipartStreamBuilder. - * - * @param MultipartStreamBuilder $builder - */ - public function addToBuilder(MultipartStreamBuilder $builder): void - { - $options = array_filter([ - 'filename' => $this->filename, - 'headers' => $this->headers, - ], fn ($value) => $value !== null); - - $builder->addResource($this->name, $this->contents, $options); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/ArrayType.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/ArrayType.php deleted file mode 100644 index a26d29008ec3..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/ArrayType.php +++ /dev/null @@ -1,16 +0,0 @@ - 'valueType'] for maps, or ['valueType'] for lists - */ - public function __construct(public array $type) - { - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Constant.php b/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Constant.php deleted file mode 100644 index 5ac4518cc6d6..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Core/Types/Constant.php +++ /dev/null @@ -1,12 +0,0 @@ -> The types allowed for this property, which can be strings, arrays, or nested Union types. - */ - public array $types; - - /** - * Constructor for the Union attribute. - * - * @param string|Union|array ...$types The list of types that the property can accept. - * This can include primitive types (e.g., 'string', 'int'), arrays, or other Union instances. - * - * Example: - * ```php - * #[Union('string', 'null', 'date', new Union('boolean', 'int'))] - * ``` - */ - public function __construct(string|Union|array ...$types) - { - $this->types = $types; - } - - /** - * Converts the Union type to a string representation. - * - * @return string A string representation of the union types. - */ - public function __toString(): string - { - return implode(' | ', array_map(function ($type) { - if (is_string($type)) { - return $type; - } elseif ($type instanceof Union) { - return (string) $type; // Recursively handle nested unions - } elseif (is_array($type)) { - return 'array'; // Handle arrays - } - }, $this->types)); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Errors/Types/UnauthorizedRequestErrorBody.php b/seed/php-sdk/basic-auth-pw-omitted/src/Errors/Types/UnauthorizedRequestErrorBody.php deleted file mode 100644 index 131d5f01b080..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Errors/Types/UnauthorizedRequestErrorBody.php +++ /dev/null @@ -1,34 +0,0 @@ -message = $values['message']; - } - - /** - * @return string - */ - public function __toString(): string - { - return $this->toJson(); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedApiException.php b/seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedApiException.php deleted file mode 100644 index 6d0bba7c39b3..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedApiException.php +++ /dev/null @@ -1,53 +0,0 @@ -body = $body; - parent::__construct($message, $statusCode, $previous); - } - - /** - * Returns the body of the response that triggered the exception. - * - * @return mixed - */ - public function getBody(): mixed - { - return $this->body; - } - - /** - * @return string - */ - public function __toString(): string - { - if (empty($this->body)) { - return $this->message . '; Status Code: ' . $this->getCode() . "\n"; - } - return $this->message . '; Status Code: ' . $this->getCode() . '; Body: ' . print_r($this->body, true) . "\n"; - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedException.php b/seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedException.php deleted file mode 100644 index 457035276737..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Exceptions/SeedException.php +++ /dev/null @@ -1,12 +0,0 @@ -, - * } $options @phpstan-ignore-next-line Property is used in endpoint methods via HttpEndpointGenerator - */ - private array $options; - - /** - * @var RawClient $client - */ - private RawClient $client; - - /** - * @param string $username The username to use for authentication. - * @param ?array{ - * baseUrl?: string, - * client?: ClientInterface, - * maxRetries?: int, - * timeout?: float, - * headers?: array, - * } $options - */ - public function __construct( - string $username, - ?array $options = null, - ) { - $defaultHeaders = [ - 'X-Fern-Language' => 'PHP', - 'X-Fern-SDK-Name' => 'Seed', - 'X-Fern-SDK-Version' => '0.0.1', - 'User-Agent' => 'seed/seed/0.0.1', - ]; - $defaultHeaders['Authorization'] = "Basic " . base64_encode($username . ":"); - - $this->options = $options ?? []; - - $this->options['headers'] = array_merge( - $defaultHeaders, - $this->options['headers'] ?? [], - ); - - $this->client = new RawClient( - options: $this->options, - ); - - $this->basicAuth = new BasicAuthClient($this->client, $this->options); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/Utils/File.php b/seed/php-sdk/basic-auth-pw-omitted/src/Utils/File.php deleted file mode 100644 index ee2af27b8909..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/Utils/File.php +++ /dev/null @@ -1,129 +0,0 @@ -filename = $filename; - $this->contentType = $contentType; - $this->stream = $stream; - } - - /** - * Creates a File instance from a filepath. - * - * @param string $filepath - * @param ?string $filename - * @param ?string $contentType - * @return File - * @throws Exception - */ - public static function createFromFilepath( - string $filepath, - ?string $filename = null, - ?string $contentType = null, - ): File { - $resource = @fopen($filepath, 'r'); - if (!$resource) { - throw new Exception("Unable to open file $filepath"); - } - $stream = Psr17FactoryDiscovery::findStreamFactory()->createStreamFromResource($resource); - if (!$stream->isReadable()) { - throw new Exception("File $filepath is not readable"); - } - return new self( - stream: $stream, - filename: $filename ?? basename($filepath), - contentType: $contentType, - ); - } - - /** - * Creates a File instance from a string. - * - * @param string $content - * @param ?string $filename - * @param ?string $contentType - * @return File - */ - public static function createFromString( - string $content, - ?string $filename, - ?string $contentType = null, - ): File { - return new self( - stream: Psr17FactoryDiscovery::findStreamFactory()->createStream($content), - filename: $filename, - contentType: $contentType, - ); - } - - /** - * Maps this File into a multipart form data part. - * - * @param string $name The name of the multipart form data part. - * @param ?string $contentType Overrides the Content-Type associated with the file, if any. - * @return MultipartFormDataPart - */ - public function toMultipartFormDataPart(string $name, ?string $contentType = null): MultipartFormDataPart - { - $contentType ??= $this->contentType; - $headers = $contentType !== null - ? ['Content-Type' => $contentType] - : null; - - return new MultipartFormDataPart( - name: $name, - value: $this->stream, - filename: $this->filename, - headers: $headers, - ); - } - - /** - * Closes the file stream. - */ - public function close(): void - { - $this->stream->close(); - } - - /** - * Destructor to ensure stream is closed. - */ - public function __destruct() - { - try { - $this->close(); - } catch (\Throwable) { - // Swallow errors during garbage collection to avoid fatal errors. - } - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php deleted file mode 100644 index 70426907841c..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example0/snippet.php +++ /dev/null @@ -1,13 +0,0 @@ -', - options: [ - 'baseUrl' => 'https://api.fern.com', - ], -); -$client->basicAuth->getWithBasicAuth(); diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php deleted file mode 100644 index 70426907841c..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example1/snippet.php +++ /dev/null @@ -1,13 +0,0 @@ -', - options: [ - 'baseUrl' => 'https://api.fern.com', - ], -); -$client->basicAuth->getWithBasicAuth(); diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php deleted file mode 100644 index 70426907841c..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example2/snippet.php +++ /dev/null @@ -1,13 +0,0 @@ -', - options: [ - 'baseUrl' => 'https://api.fern.com', - ], -); -$client->basicAuth->getWithBasicAuth(); diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php deleted file mode 100644 index 50c374428362..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example3/snippet.php +++ /dev/null @@ -1,17 +0,0 @@ -', - options: [ - 'baseUrl' => 'https://api.fern.com', - ], -); -$client->basicAuth->postWithBasicAuth( - [ - 'key' => "value", - ], -); diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php deleted file mode 100644 index 50c374428362..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example4/snippet.php +++ /dev/null @@ -1,17 +0,0 @@ -', - options: [ - 'baseUrl' => 'https://api.fern.com', - ], -); -$client->basicAuth->postWithBasicAuth( - [ - 'key' => "value", - ], -); diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php deleted file mode 100644 index 50c374428362..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example5/snippet.php +++ /dev/null @@ -1,17 +0,0 @@ -', - options: [ - 'baseUrl' => 'https://api.fern.com', - ], -); -$client->basicAuth->postWithBasicAuth( - [ - 'key' => "value", - ], -); diff --git a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php b/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php deleted file mode 100644 index 50c374428362..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/src/dynamic-snippets/example6/snippet.php +++ /dev/null @@ -1,17 +0,0 @@ -', - options: [ - 'baseUrl' => 'https://api.fern.com', - ], -); -$client->basicAuth->postWithBasicAuth( - [ - 'key' => "value", - ], -); diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Client/RawClientTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Client/RawClientTest.php deleted file mode 100644 index df36dc918894..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Client/RawClientTest.php +++ /dev/null @@ -1,1074 +0,0 @@ -name = $values['name']; - } - - /** - * @return string - */ - public function getName(): ?string - { - return $this->name; - } -} - -class RawClientTest extends TestCase -{ - private string $baseUrl = 'https://api.example.com'; - private MockHttpClient $mockClient; - private RawClient $rawClient; - - protected function setUp(): void - { - $this->mockClient = new MockHttpClient(); - $this->rawClient = new RawClient(['client' => $this->mockClient, 'maxRetries' => 0]); - } - - /** - * @throws ClientExceptionInterface - */ - public function testHeaders(): void - { - $this->mockClient->append(self::createResponse(200)); - - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::GET, - ['X-Custom-Header' => 'TestValue'] - ); - - $this->rawClient->sendRequest($request); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); - $this->assertEquals('TestValue', $lastRequest->getHeaderLine('X-Custom-Header')); - } - - /** - * @throws ClientExceptionInterface - */ - public function testQueryParameters(): void - { - $this->mockClient->append(self::createResponse(200)); - - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::GET, - [], - ['param1' => 'value1', 'param2' => ['a', 'b'], 'param3' => 'true'] - ); - - $this->rawClient->sendRequest($request); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - $this->assertEquals( - 'https://api.example.com/test?param1=value1¶m2=a¶m2=b¶m3=true', - (string)$lastRequest->getUri() - ); - } - - /** - * @throws ClientExceptionInterface - */ - public function testJsonBody(): void - { - $this->mockClient->append(self::createResponse(200)); - - $body = ['key' => 'value']; - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::POST, - [], - [], - $body - ); - - $this->rawClient->sendRequest($request); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); - $this->assertEquals(JsonEncoder::encode($body), (string)$lastRequest->getBody()); - } - - public function testAdditionalHeaders(): void - { - $this->mockClient->append(self::createResponse(200)); - - $body = new JsonRequest([ - 'name' => 'john.doe' - ]); - $headers = [ - 'X-API-Version' => '1.0.0', - ]; - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::POST, - $headers, - [], - $body - ); - - $this->rawClient->sendRequest( - $request, - options: [ - 'headers' => [ - 'X-Tenancy' => 'test' - ] - ] - ); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); - $this->assertEquals('1.0.0', $lastRequest->getHeaderLine('X-API-Version')); - $this->assertEquals('test', $lastRequest->getHeaderLine('X-Tenancy')); - } - - public function testOverrideAdditionalHeaders(): void - { - $this->mockClient->append(self::createResponse(200)); - - $body = new JsonRequest([ - 'name' => 'john.doe' - ]); - $headers = [ - 'X-API-Version' => '1.0.0', - ]; - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::POST, - $headers, - [], - $body - ); - - $this->rawClient->sendRequest( - $request, - options: [ - 'headers' => [ - 'X-API-Version' => '2.0.0' - ] - ] - ); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); - $this->assertEquals('2.0.0', $lastRequest->getHeaderLine('X-API-Version')); - } - - public function testAdditionalBodyProperties(): void - { - $this->mockClient->append(self::createResponse(200)); - - $body = new JsonRequest([ - 'name' => 'john.doe' - ]); - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::POST, - [], - [], - $body - ); - - $this->rawClient->sendRequest( - $request, - options: [ - 'bodyProperties' => [ - 'age' => 42 - ] - ] - ); - - $expectedJson = [ - 'name' => 'john.doe', - 'age' => 42 - ]; - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); - $this->assertEquals(JsonEncoder::encode($expectedJson), (string)$lastRequest->getBody()); - } - - public function testOverrideAdditionalBodyProperties(): void - { - $this->mockClient->append(self::createResponse(200)); - - $body = [ - 'name' => 'john.doe' - ]; - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::POST, - [], - [], - $body - ); - - $this->rawClient->sendRequest( - $request, - options: [ - 'bodyProperties' => [ - 'name' => 'jane.doe' - ] - ] - ); - - $expectedJson = [ - 'name' => 'jane.doe', - ]; - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); - $this->assertEquals(JsonEncoder::encode($expectedJson), (string)$lastRequest->getBody()); - } - - public function testAdditionalQueryParameters(): void - { - $this->mockClient->append(self::createResponse(200)); - - $query = ['key' => 'value']; - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::POST, - [], - $query, - [] - ); - - $this->rawClient->sendRequest( - $request, - options: [ - 'queryParameters' => [ - 'extra' => 42 - ] - ] - ); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); - $this->assertEquals('key=value&extra=42', $lastRequest->getUri()->getQuery()); - } - - public function testOverrideQueryParameters(): void - { - $this->mockClient->append(self::createResponse(200)); - - $query = ['key' => 'invalid']; - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::POST, - [], - $query, - [] - ); - - $this->rawClient->sendRequest( - $request, - options: [ - 'queryParameters' => [ - 'key' => 'value' - ] - ] - ); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - $this->assertEquals('application/json', $lastRequest->getHeaderLine('Content-Type')); - $this->assertEquals('key=value', $lastRequest->getUri()->getQuery()); - } - - public function testDefaultRetries(): void - { - $this->mockClient->append(self::createResponse(500)); - - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::GET - ); - - $response = $this->rawClient->sendRequest($request); - $this->assertEquals(500, $response->getStatusCode()); - $this->assertEquals(0, $this->mockClient->count()); - } - - /** - * @throws ClientExceptionInterface - */ - public function testExplicitRetriesSuccess(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append(self::createResponse(500), self::createResponse(500), self::createResponse(200)); - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 2, - sleepFunction: function (int $_microseconds): void { - }, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $response = $retryClient->sendRequest($request); - - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals(0, $mockClient->count()); - } - - public function testExplicitRetriesFailure(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append(self::createResponse(500), self::createResponse(500), self::createResponse(500)); - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 2, - sleepFunction: function (int $_microseconds): void { - }, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $response = $retryClient->sendRequest($request); - - $this->assertEquals(500, $response->getStatusCode()); - $this->assertEquals(0, $mockClient->count()); - } - - /** - * @throws ClientExceptionInterface - */ - public function testShouldRetryOnStatusCodes(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append( - self::createResponse(408), - self::createResponse(429), - self::createResponse(500), - self::createResponse(501), - self::createResponse(502), - self::createResponse(503), - self::createResponse(504), - self::createResponse(505), - self::createResponse(599), - self::createResponse(200), - ); - $countOfErrorRequests = $mockClient->count() - 1; - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: $countOfErrorRequests, - sleepFunction: function (int $_microseconds): void { - }, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $response = $retryClient->sendRequest($request); - - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals(0, $mockClient->count()); - } - - public function testShouldFailOn400Response(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append(self::createResponse(400), self::createResponse(200)); - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 2, - sleepFunction: function (int $_microseconds): void { - }, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $response = $retryClient->sendRequest($request); - - $this->assertEquals(400, $response->getStatusCode()); - $this->assertEquals(1, $mockClient->count()); - } - - public function testRetryAfterSecondsHeaderControlsDelay(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append( - self::createResponse(503, ['Retry-After' => '10']), - self::createResponse(200), - ); - - $capturedDelays = []; - $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { - $capturedDelays[] = (int) ($microseconds / 1000); // Convert microseconds to milliseconds - }; - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 2, - baseDelay: 1000, - sleepFunction: $sleepFunction, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $retryClient->sendRequest($request); - - $this->assertCount(1, $capturedDelays); - $this->assertGreaterThanOrEqual(10000, $capturedDelays[0]); - $this->assertLessThanOrEqual(12000, $capturedDelays[0]); - } - - public function testRetryAfterHttpDateHeaderIsHandled(): void - { - $retryAfterDate = gmdate('D, d M Y H:i:s \G\M\T', time() + 5); - - $mockClient = new MockHttpClient(); - $mockClient->append( - self::createResponse(503, ['Retry-After' => $retryAfterDate]), - self::createResponse(200), - ); - - $capturedDelays = []; - $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { - $capturedDelays[] = (int) ($microseconds / 1000); - }; - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 2, - baseDelay: 1000, - sleepFunction: $sleepFunction, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $retryClient->sendRequest($request); - - $this->assertCount(1, $capturedDelays); - $this->assertGreaterThan(0, $capturedDelays[0]); - $this->assertLessThanOrEqual(60000, $capturedDelays[0]); - } - - public function testRateLimitResetHeaderControlsDelay(): void - { - $resetTime = (int) floor(microtime(true)) + 5; - - $mockClient = new MockHttpClient(); - $mockClient->append( - self::createResponse(429, ['X-RateLimit-Reset' => (string) $resetTime]), - self::createResponse(200), - ); - - $capturedDelays = []; - $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { - $capturedDelays[] = (int) ($microseconds / 1000); - }; - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 2, - baseDelay: 1000, - sleepFunction: $sleepFunction, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $retryClient->sendRequest($request); - - $this->assertCount(1, $capturedDelays); - $this->assertGreaterThan(0, $capturedDelays[0]); - $this->assertLessThanOrEqual(60000, $capturedDelays[0]); - } - - public function testRateLimitResetHeaderRespectsMaxDelayAndPositiveJitter(): void - { - $resetTime = (int) floor(microtime(true)) + 1000; - - $mockClient = new MockHttpClient(); - $mockClient->append( - self::createResponse(429, ['X-RateLimit-Reset' => (string) $resetTime]), - self::createResponse(200), - ); - - $capturedDelays = []; - $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { - $capturedDelays[] = (int) ($microseconds / 1000); - }; - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 1, - baseDelay: 1000, - sleepFunction: $sleepFunction, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $retryClient->sendRequest($request); - - $this->assertCount(1, $capturedDelays); - $this->assertGreaterThanOrEqual(60000, $capturedDelays[0]); - $this->assertLessThanOrEqual(72000, $capturedDelays[0]); - } - - public function testExponentialBackoffWithSymmetricJitterWhenNoHeaders(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append( - self::createResponse(503), - self::createResponse(200), - ); - - $capturedDelays = []; - $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { - $capturedDelays[] = (int) ($microseconds / 1000); - }; - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 1, - baseDelay: 1000, - sleepFunction: $sleepFunction, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $retryClient->sendRequest($request); - - $this->assertCount(1, $capturedDelays); - $this->assertGreaterThanOrEqual(900, $capturedDelays[0]); - $this->assertLessThanOrEqual(1100, $capturedDelays[0]); - } - - public function testRetryAfterHeaderTakesPrecedenceOverRateLimitReset(): void - { - $resetTime = (int) floor(microtime(true)) + 30; - - $mockClient = new MockHttpClient(); - $mockClient->append( - self::createResponse(503, [ - 'Retry-After' => '5', - 'X-RateLimit-Reset' => (string) $resetTime, - ]), - self::createResponse(200), - ); - - $capturedDelays = []; - $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { - $capturedDelays[] = (int) ($microseconds / 1000); - }; - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 2, - baseDelay: 1000, - sleepFunction: $sleepFunction, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $retryClient->sendRequest($request); - - $this->assertCount(1, $capturedDelays); - $this->assertGreaterThanOrEqual(5000, $capturedDelays[0]); - $this->assertLessThanOrEqual(6000, $capturedDelays[0]); - } - - public function testMaxDelayCapIsApplied(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append( - self::createResponse(503, ['Retry-After' => '120']), - self::createResponse(200), - ); - - $capturedDelays = []; - $sleepFunction = function (int $microseconds) use (&$capturedDelays): void { - $capturedDelays[] = (int) ($microseconds / 1000); - }; - - $retryClient = new RetryDecoratingClient( - $mockClient, - maxRetries: 2, - baseDelay: 1000, - sleepFunction: $sleepFunction, - ); - - $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); - $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); - - $retryClient->sendRequest($request); - - $this->assertCount(1, $capturedDelays); - $this->assertGreaterThanOrEqual(60000, $capturedDelays[0]); - $this->assertLessThanOrEqual(72000, $capturedDelays[0]); - } - - public function testMultipartContentTypeIncludesBoundary(): void - { - $this->mockClient->append(self::createResponse(200)); - - $formData = new MultipartFormData(); - $formData->add('field', 'value'); - - $request = new MultipartApiRequest( - $this->baseUrl, - '/upload', - HttpMethod::POST, - [], - [], - $formData, - ); - - $this->rawClient->sendRequest($request); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - - $contentType = $lastRequest->getHeaderLine('Content-Type'); - $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); - - $boundary = substr($contentType, strlen('multipart/form-data; boundary=')); - $body = (string) $lastRequest->getBody(); - $this->assertStringContainsString("--{$boundary}\r\n", $body); - $this->assertStringContainsString("Content-Disposition: form-data; name=\"field\"\r\n", $body); - $this->assertStringContainsString("value", $body); - $this->assertStringContainsString("--{$boundary}--\r\n", $body); - } - - public function testMultipartWithFilename(): void - { - $this->mockClient->append(self::createResponse(200)); - - $formData = new MultipartFormData(); - $formData->addPart(new MultipartFormDataPart( - name: 'document', - value: 'file-contents', - filename: 'report.pdf', - headers: ['Content-Type' => 'application/pdf'], - )); - - $request = new MultipartApiRequest( - $this->baseUrl, - '/upload', - HttpMethod::POST, - [], - [], - $formData, - ); - - $this->rawClient->sendRequest($request); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - - $body = (string) $lastRequest->getBody(); - $this->assertStringContainsString( - 'Content-Disposition: form-data; name="document"; filename="report.pdf"', - $body, - ); - $this->assertStringContainsString('Content-Type: application/pdf', $body); - $this->assertStringContainsString('file-contents', $body); - } - - public function testMultipartWithMultipleParts(): void - { - $this->mockClient->append(self::createResponse(200)); - - $formData = new MultipartFormData(); - $formData->add('name', 'John'); - $formData->add('age', 30); - $formData->addPart(new MultipartFormDataPart( - name: 'avatar', - value: 'image-data', - filename: 'avatar.png', - )); - - $request = new MultipartApiRequest( - $this->baseUrl, - '/profile', - HttpMethod::POST, - [], - [], - $formData, - ); - - $this->rawClient->sendRequest($request); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - - $body = (string) $lastRequest->getBody(); - $this->assertStringContainsString('name="name"', $body); - $this->assertStringContainsString('John', $body); - $this->assertStringContainsString('name="age"', $body); - $this->assertStringContainsString('30', $body); - $this->assertStringContainsString('name="avatar"; filename="avatar.png"', $body); - $this->assertStringContainsString('image-data', $body); - } - - public function testMultipartDoesNotIncludeJsonContentType(): void - { - $this->mockClient->append(self::createResponse(200)); - - $formData = new MultipartFormData(); - $formData->add('field', 'value'); - - $request = new MultipartApiRequest( - $this->baseUrl, - '/upload', - HttpMethod::POST, - [], - [], - $formData, - ); - - $this->rawClient->sendRequest($request); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - - $contentType = $lastRequest->getHeaderLine('Content-Type'); - $this->assertStringStartsWith('multipart/form-data; boundary=', $contentType); - $this->assertStringNotContainsString('application/json', $contentType); - } - - public function testMultipartNullBodySendsNoBody(): void - { - $this->mockClient->append(self::createResponse(200)); - - $request = new MultipartApiRequest( - $this->baseUrl, - '/upload', - HttpMethod::POST, - ); - - $this->rawClient->sendRequest($request); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - - $this->assertEquals('', (string) $lastRequest->getBody()); - $this->assertStringNotContainsString('multipart/form-data', $lastRequest->getHeaderLine('Content-Type')); - } - - public function testJsonNullBodySendsNoBody(): void - { - $this->mockClient->append(self::createResponse(200)); - - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::POST, - ); - - $this->rawClient->sendRequest($request); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - - $this->assertEquals('', (string) $lastRequest->getBody()); - } - - public function testEmptyJsonBodySerializesAsObject(): void - { - $this->mockClient->append(self::createResponse(200)); - - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::POST, - [], - [], - ['key' => 'value'], - ); - - $this->rawClient->sendRequest( - $request, - options: [ - 'bodyProperties' => [ - 'key' => 'value', - ], - ], - ); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - - // When bodyProperties override all keys, the merged result should still - // serialize as a JSON object {}, not an array []. - $decoded = json_decode((string) $lastRequest->getBody(), true); - $this->assertIsArray($decoded); - $this->assertEquals('value', $decoded['key']); - } - - public function testAuthHeadersAreIncluded(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append(self::createResponse(200)); - - $rawClient = new RawClient([ - 'client' => $mockClient, - 'maxRetries' => 0, - 'getAuthHeaders' => fn () => ['Authorization' => 'Bearer test-token'], - ]); - - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::GET, - ); - - $rawClient->sendRequest($request); - - $lastRequest = $mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - - $this->assertEquals('Bearer test-token', $lastRequest->getHeaderLine('Authorization')); - } - - public function testAuthHeadersAreIncludedInMultipart(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append(self::createResponse(200)); - - $rawClient = new RawClient([ - 'client' => $mockClient, - 'maxRetries' => 0, - 'getAuthHeaders' => fn () => ['Authorization' => 'Bearer test-token'], - ]); - - $formData = new MultipartFormData(); - $formData->add('field', 'value'); - - $request = new MultipartApiRequest( - $this->baseUrl, - '/upload', - HttpMethod::POST, - [], - [], - $formData, - ); - - $rawClient->sendRequest($request); - - $lastRequest = $mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - - $this->assertEquals('Bearer test-token', $lastRequest->getHeaderLine('Authorization')); - $this->assertStringStartsWith('multipart/form-data; boundary=', $lastRequest->getHeaderLine('Content-Type')); - } - - /** - * Creates a PSR-7 response using discovery, without depending on any specific implementation. - * - * @param int $statusCode - * @param array $headers - * @param string $body - * @return ResponseInterface - */ - private static function createResponse( - int $statusCode = 200, - array $headers = [], - string $body = '', - ): ResponseInterface { - $response = \Http\Discovery\Psr17FactoryDiscovery::findResponseFactory() - ->createResponse($statusCode); - foreach ($headers as $name => $value) { - $response = $response->withHeader($name, $value); - } - if ($body !== '') { - $response = $response->withBody( - \Http\Discovery\Psr17FactoryDiscovery::findStreamFactory() - ->createStream($body), - ); - } - return $response; - } - - - public function testTimeoutOptionIsAccepted(): void - { - $this->mockClient->append(self::createResponse(200)); - - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::GET, - ); - - // MockHttpClient is not Guzzle/Symfony, so a warning is triggered once. - set_error_handler(static function (int $errno, string $errstr): bool { - return $errno === E_USER_WARNING - && str_contains($errstr, 'Timeout option is not supported'); - }); - - try { - $response = $this->rawClient->sendRequest( - $request, - options: [ - 'timeout' => 3.0 - ] - ); - - $this->assertEquals(200, $response->getStatusCode()); - - $lastRequest = $this->mockClient->getLastRequest(); - $this->assertInstanceOf(RequestInterface::class, $lastRequest); - } finally { - restore_error_handler(); - } - } - - public function testClientLevelTimeoutIsAccepted(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append(self::createResponse(200)); - - $rawClient = new RawClient([ - 'client' => $mockClient, - 'maxRetries' => 0, - 'timeout' => 5.0, - ]); - - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::GET, - ); - - set_error_handler(static function (int $errno, string $errstr): bool { - return $errno === E_USER_WARNING - && str_contains($errstr, 'Timeout option is not supported'); - }); - - try { - $response = $rawClient->sendRequest($request); - $this->assertEquals(200, $response->getStatusCode()); - } finally { - restore_error_handler(); - } - } - - public function testPerRequestTimeoutOverridesClientTimeout(): void - { - $mockClient = new MockHttpClient(); - $mockClient->append(self::createResponse(200)); - - $rawClient = new RawClient([ - 'client' => $mockClient, - 'maxRetries' => 0, - 'timeout' => 5.0, - ]); - - $request = new JsonApiRequest( - $this->baseUrl, - '/test', - HttpMethod::GET, - ); - - set_error_handler(static function (int $errno, string $errstr): bool { - return $errno === E_USER_WARNING - && str_contains($errstr, 'Timeout option is not supported'); - }); - - try { - $response = $rawClient->sendRequest( - $request, - options: [ - 'timeout' => 1.0 - ] - ); - - $this->assertEquals(200, $response->getStatusCode()); - } finally { - restore_error_handler(); - } - } - - public function testDiscoveryFindsHttpClient(): void - { - // HttpClientBuilder::build() with no client arg uses Psr18ClientDiscovery. - $client = HttpClientBuilder::build(); - $this->assertInstanceOf(\Psr\Http\Client\ClientInterface::class, $client); - } - - public function testDiscoveryFindsFactories(): void - { - $requestFactory = HttpClientBuilder::requestFactory(); - $this->assertInstanceOf(\Psr\Http\Message\RequestFactoryInterface::class, $requestFactory); - - $streamFactory = HttpClientBuilder::streamFactory(); - $this->assertInstanceOf(\Psr\Http\Message\StreamFactoryInterface::class, $streamFactory); - - // Verify they produce usable objects - $request = $requestFactory->createRequest('GET', 'https://example.com'); - $this->assertEquals('GET', $request->getMethod()); - - $stream = $streamFactory->createStream('hello'); - $this->assertEquals('hello', (string) $stream); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/AdditionalPropertiesTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/AdditionalPropertiesTest.php deleted file mode 100644 index 2c32002340e7..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/AdditionalPropertiesTest.php +++ /dev/null @@ -1,76 +0,0 @@ -name; - } - - /** - * @return string|null - */ - public function getEmail(): ?string - { - return $this->email; - } - - /** - * @param array{ - * name: string, - * email?: string|null, - * } $values - */ - public function __construct( - array $values, - ) { - $this->name = $values['name']; - $this->email = $values['email'] ?? null; - } -} - -class AdditionalPropertiesTest extends TestCase -{ - public function testExtraProperties(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'name' => 'john.doe', - 'email' => 'john.doe@example.com', - 'age' => 42 - ], - ); - - $person = Person::fromJson($expectedJson); - $this->assertEquals('john.doe', $person->getName()); - $this->assertEquals('john.doe@example.com', $person->getEmail()); - $this->assertEquals( - [ - 'age' => 42 - ], - $person->getAdditionalProperties(), - ); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/DateArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/DateArrayTest.php deleted file mode 100644 index e7794d652432..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/DateArrayTest.php +++ /dev/null @@ -1,54 +0,0 @@ -dates = $values['dates']; - } -} - -class DateArrayTest extends TestCase -{ - public function testDateTimeInArrays(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'dates' => ['2023-01-01', '2023-02-01', '2023-03-01'] - ], - ); - - $object = DateArray::fromJson($expectedJson); - $this->assertInstanceOf(DateTime::class, $object->dates[0], 'dates[0] should be a DateTime instance.'); - $this->assertEquals('2023-01-01', $object->dates[0]->format('Y-m-d'), 'dates[0] should have the correct date.'); - $this->assertInstanceOf(DateTime::class, $object->dates[1], 'dates[1] should be a DateTime instance.'); - $this->assertEquals('2023-02-01', $object->dates[1]->format('Y-m-d'), 'dates[1] should have the correct date.'); - $this->assertInstanceOf(DateTime::class, $object->dates[2], 'dates[2] should be a DateTime instance.'); - $this->assertEquals('2023-03-01', $object->dates[2]->format('Y-m-d'), 'dates[2] should have the correct date.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for dates array.'); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EmptyArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EmptyArrayTest.php deleted file mode 100644 index b5f217e01f76..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EmptyArrayTest.php +++ /dev/null @@ -1,71 +0,0 @@ - $emptyMapArray - */ - #[JsonProperty('empty_map_array')] - #[ArrayType(['integer' => new Union('string', 'null')])] - public array $emptyMapArray; - - /** - * @var array $emptyDatesArray - */ - #[ArrayType([new Union('date', 'null')])] - #[JsonProperty('empty_dates_array')] - public array $emptyDatesArray; - - /** - * @param array{ - * emptyStringArray: string[], - * emptyMapArray: array, - * emptyDatesArray: array, - * } $values - */ - public function __construct( - array $values, - ) { - $this->emptyStringArray = $values['emptyStringArray']; - $this->emptyMapArray = $values['emptyMapArray']; - $this->emptyDatesArray = $values['emptyDatesArray']; - } -} - -class EmptyArrayTest extends TestCase -{ - public function testEmptyArray(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'empty_string_array' => [], - 'empty_map_array' => [], - 'empty_dates_array' => [] - ], - ); - - $object = EmptyArray::fromJson($expectedJson); - $this->assertEmpty($object->emptyStringArray, 'empty_string_array should be empty.'); - $this->assertEmpty($object->emptyMapArray, 'empty_map_array should be empty.'); - $this->assertEmpty($object->emptyDatesArray, 'empty_dates_array should be empty.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for EmptyArraysType.'); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EnumTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EnumTest.php deleted file mode 100644 index 72dc6f2cfa00..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/EnumTest.php +++ /dev/null @@ -1,77 +0,0 @@ -value; - } -} - -class ShapeType extends JsonSerializableType -{ - /** - * @var Shape $shape - */ - #[JsonProperty('shape')] - public Shape $shape; - - /** - * @var Shape[] $shapes - */ - #[ArrayType([Shape::class])] - #[JsonProperty('shapes')] - public array $shapes; - - /** - * @param Shape $shape - * @param Shape[] $shapes - */ - public function __construct( - Shape $shape, - array $shapes, - ) { - $this->shape = $shape; - $this->shapes = $shapes; - } -} - -class EnumTest extends TestCase -{ - public function testEnumSerialization(): void - { - $object = new ShapeType( - Shape::Circle, - [Shape::Square, Shape::Circle, Shape::Triangle] - ); - - $expectedJson = JsonEncoder::encode([ - 'shape' => 'CIRCLE', - 'shapes' => ['SQUARE', 'CIRCLE', 'TRIANGLE'] - ]); - - $actualJson = $object->toJson(); - - $this->assertJsonStringEqualsJsonString( - $expectedJson, - $actualJson, - 'Serialized JSON does not match expected JSON for shape and shapes properties.' - ); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ExhaustiveTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ExhaustiveTest.php deleted file mode 100644 index 4c288378b48b..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ExhaustiveTest.php +++ /dev/null @@ -1,197 +0,0 @@ -nestedProperty = $values['nestedProperty']; - } -} - -class Type extends JsonSerializableType -{ - /** - * @var Nested nestedType - */ - #[JsonProperty('nested_type')] - public Nested $nestedType; /** - - * @var string $simpleProperty - */ - #[JsonProperty('simple_property')] - public string $simpleProperty; - - /** - * @var DateTime $dateProperty - */ - #[Date(Date::TYPE_DATE)] - #[JsonProperty('date_property')] - public DateTime $dateProperty; - - /** - * @var DateTime $datetimeProperty - */ - #[Date(Date::TYPE_DATETIME)] - #[JsonProperty('datetime_property')] - public DateTime $datetimeProperty; - - /** - * @var array $stringArray - */ - #[ArrayType(['string'])] - #[JsonProperty('string_array')] - public array $stringArray; - - /** - * @var array $mapProperty - */ - #[ArrayType(['string' => 'integer'])] - #[JsonProperty('map_property')] - public array $mapProperty; - - /** - * @var array $objectArray - */ - #[ArrayType(['integer' => new Union(Nested::class, 'null')])] - #[JsonProperty('object_array')] - public array $objectArray; - - /** - * @var array> $nestedArray - */ - #[ArrayType(['integer' => ['integer' => new Union('string', 'null')]])] - #[JsonProperty('nested_array')] - public array $nestedArray; - - /** - * @var array $datesArray - */ - #[ArrayType([new Union('date', 'null')])] - #[JsonProperty('dates_array')] - public array $datesArray; - - /** - * @var string|null $nullableProperty - */ - #[JsonProperty('nullable_property')] - public ?string $nullableProperty; - - /** - * @param array{ - * nestedType: Nested, - * simpleProperty: string, - * dateProperty: DateTime, - * datetimeProperty: DateTime, - * stringArray: array, - * mapProperty: array, - * objectArray: array, - * nestedArray: array>, - * datesArray: array, - * nullableProperty?: string|null, - * } $values - */ - public function __construct( - array $values, - ) { - $this->nestedType = $values['nestedType']; - $this->simpleProperty = $values['simpleProperty']; - $this->dateProperty = $values['dateProperty']; - $this->datetimeProperty = $values['datetimeProperty']; - $this->stringArray = $values['stringArray']; - $this->mapProperty = $values['mapProperty']; - $this->objectArray = $values['objectArray']; - $this->nestedArray = $values['nestedArray']; - $this->datesArray = $values['datesArray']; - $this->nullableProperty = $values['nullableProperty'] ?? null; - } -} - -class ExhaustiveTest extends TestCase -{ - /** - * Test serialization and deserialization of all types in Type. - */ - public function testExhaustive(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'nested_type' => ['nested_property' => '1995-07-20'], - 'simple_property' => 'Test String', - // Omit 'nullable_property' to test null serialization - 'date_property' => '2023-01-01', - 'datetime_property' => '2023-01-01T12:34:56Z', - 'string_array' => ['one', 'two', 'three'], - 'map_property' => ['key1' => 1, 'key2' => 2], - 'object_array' => [ - 1 => ['nested_property' => '2021-07-20'], - 2 => null, // Testing nullable objects in array - ], - 'nested_array' => [ - 1 => [1 => 'value1', 2 => null], // Testing nullable strings in nested array - 2 => [3 => 'value3', 4 => 'value4'] - ], - 'dates_array' => ['2023-01-01', null, '2023-03-01'] // Testing nullable dates in array> - ], - ); - - $object = Type::fromJson($expectedJson); - - // Check that nullable property is null and not included in JSON - $this->assertNull($object->nullableProperty, 'Nullable property should be null.'); - - // Check date properties - $this->assertInstanceOf(DateTime::class, $object->dateProperty, 'date_property should be a DateTime instance.'); - $this->assertEquals('2023-01-01', $object->dateProperty->format('Y-m-d'), 'date_property should have the correct date.'); - $this->assertInstanceOf(DateTime::class, $object->datetimeProperty, 'datetime_property should be a DateTime instance.'); - $this->assertEquals('2023-01-01 12:34:56', $object->datetimeProperty->format('Y-m-d H:i:s'), 'datetime_property should have the correct datetime.'); - - // Check scalar arrays - $this->assertEquals(['one', 'two', 'three'], $object->stringArray, 'string_array should match the original data.'); - $this->assertEquals(['key1' => 1, 'key2' => 2], $object->mapProperty, 'map_property should match the original data.'); - - // Check object array with nullable elements - $this->assertInstanceOf(Nested::class, $object->objectArray[1], 'object_array[1] should be an instance of TestNestedType1.'); - $this->assertEquals('2021-07-20', $object->objectArray[1]->nestedProperty->format('Y-m-d'), 'object_array[1]->nestedProperty should match the original data.'); - $this->assertNull($object->objectArray[2], 'object_array[2] should be null.'); - - // Check nested array with nullable strings - $this->assertEquals('value1', $object->nestedArray[1][1], 'nested_array[1][1] should match the original data.'); - $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); - $this->assertEquals('value3', $object->nestedArray[2][3], 'nested_array[2][3] should match the original data.'); - $this->assertEquals('value4', $object->nestedArray[2][4], 'nested_array[2][4] should match the original data.'); - - // Check dates array with nullable DateTime objects - $this->assertInstanceOf(DateTime::class, $object->datesArray[0], 'dates_array[0] should be a DateTime instance.'); - $this->assertEquals('2023-01-01', $object->datesArray[0]->format('Y-m-d'), 'dates_array[0] should have the correct date.'); - $this->assertNull($object->datesArray[1], 'dates_array[1] should be null.'); - $this->assertInstanceOf(DateTime::class, $object->datesArray[2], 'dates_array[2] should be a DateTime instance.'); - $this->assertEquals('2023-03-01', $object->datesArray[2]->format('Y-m-d'), 'dates_array[2] should have the correct date.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'The serialized JSON does not match the original JSON.'); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/InvalidTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/InvalidTest.php deleted file mode 100644 index 9d845ea113b8..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/InvalidTest.php +++ /dev/null @@ -1,42 +0,0 @@ -integerProperty = $values['integerProperty']; - } -} - -class InvalidTest extends TestCase -{ - public function testInvalidJsonThrowsException(): void - { - $this->expectException(\TypeError::class); - $json = JsonEncoder::encode( - [ - 'integer_property' => 'not_an_integer' - ], - ); - Invalid::fromJson($json); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NestedUnionArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NestedUnionArrayTest.php deleted file mode 100644 index 8fbbeb939f02..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NestedUnionArrayTest.php +++ /dev/null @@ -1,89 +0,0 @@ -nestedProperty = $values['nestedProperty']; - } -} - -class NestedUnionArray extends JsonSerializableType -{ - /** - * @var array> $nestedArray - */ - #[ArrayType(['integer' => ['integer' => new Union(UnionObject::class, 'null', 'date')]])] - #[JsonProperty('nested_array')] - public array $nestedArray; - - /** - * @param array{ - * nestedArray: array>, - * } $values - */ - public function __construct( - array $values, - ) { - $this->nestedArray = $values['nestedArray']; - } -} - -class NestedUnionArrayTest extends TestCase -{ - public function testNestedUnionArray(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'nested_array' => [ - 1 => [ - 1 => ['nested_property' => 'Nested One'], - 2 => null, - 4 => '2023-01-02' - ], - 2 => [ - 5 => ['nested_property' => 'Nested Two'], - 7 => '2023-02-02' - ] - ] - ], - ); - - $object = NestedUnionArray::fromJson($expectedJson); - $this->assertInstanceOf(UnionObject::class, $object->nestedArray[1][1], 'nested_array[1][1] should be an instance of Object.'); - $this->assertEquals('Nested One', $object->nestedArray[1][1]->nestedProperty, 'nested_array[1][1]->nestedProperty should match the original data.'); - $this->assertNull($object->nestedArray[1][2], 'nested_array[1][2] should be null.'); - $this->assertInstanceOf(DateTime::class, $object->nestedArray[1][4], 'nested_array[1][4] should be a DateTime instance.'); - $this->assertEquals('2023-01-02T00:00:00+00:00', $object->nestedArray[1][4]->format(Constant::DateTimeFormat), 'nested_array[1][4] should have the correct datetime.'); - $this->assertInstanceOf(UnionObject::class, $object->nestedArray[2][5], 'nested_array[2][5] should be an instance of Object.'); - $this->assertEquals('Nested Two', $object->nestedArray[2][5]->nestedProperty, 'nested_array[2][5]->nestedProperty should match the original data.'); - $this->assertInstanceOf(DateTime::class, $object->nestedArray[2][7], 'nested_array[1][4] should be a DateTime instance.'); - $this->assertEquals('2023-02-02', $object->nestedArray[2][7]->format('Y-m-d'), 'nested_array[1][4] should have the correct date.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nested_array.'); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullPropertyTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullPropertyTest.php deleted file mode 100644 index ce20a2442825..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullPropertyTest.php +++ /dev/null @@ -1,53 +0,0 @@ -nonNullProperty = $values['nonNullProperty']; - $this->nullProperty = $values['nullProperty'] ?? null; - } -} - -class NullPropertyTest extends TestCase -{ - public function testNullPropertiesAreOmitted(): void - { - $object = new NullProperty( - [ - "nonNullProperty" => "Test String", - "nullProperty" => null - ] - ); - - $serialized = $object->jsonSerialize(); - $this->assertArrayHasKey('non_null_property', $serialized, 'non_null_property should be present in the serialized JSON.'); - $this->assertArrayNotHasKey('null_property', $serialized, 'null_property should be omitted from the serialized JSON.'); - $this->assertEquals('Test String', $serialized['non_null_property'], 'non_null_property should have the correct value.'); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullableArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullableArrayTest.php deleted file mode 100644 index d1749c434a4c..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/NullableArrayTest.php +++ /dev/null @@ -1,49 +0,0 @@ - $nullableStringArray - */ - #[ArrayType([new Union('string', 'null')])] - #[JsonProperty('nullable_string_array')] - public array $nullableStringArray; - - /** - * @param array{ - * nullableStringArray: array, - * } $values - */ - public function __construct( - array $values, - ) { - $this->nullableStringArray = $values['nullableStringArray']; - } -} - -class NullableArrayTest extends TestCase -{ - public function testNullableArray(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'nullable_string_array' => ['one', null, 'three'] - ], - ); - - $object = NullableArray::fromJson($expectedJson); - $this->assertEquals(['one', null, 'three'], $object->nullableStringArray, 'nullable_string_array should match the original data.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for nullable_string_array.'); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ScalarTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ScalarTest.php deleted file mode 100644 index ad4db0251bb5..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/ScalarTest.php +++ /dev/null @@ -1,116 +0,0 @@ - $intFloatArray - */ - #[ArrayType([new Union('integer', 'float')])] - #[JsonProperty('int_float_array')] - public array $intFloatArray; - - /** - * @var array $floatArray - */ - #[ArrayType(['float'])] - #[JsonProperty('float_array')] - public array $floatArray; - - /** - * @var bool|null $nullableBooleanProperty - */ - #[JsonProperty('nullable_boolean_property')] - public ?bool $nullableBooleanProperty; - - /** - * @param array{ - * integerProperty: int, - * floatProperty: float, - * otherFloatProperty: float, - * booleanProperty: bool, - * stringProperty: string, - * intFloatArray: array, - * floatArray: array, - * nullableBooleanProperty?: bool|null, - * } $values - */ - public function __construct( - array $values, - ) { - $this->integerProperty = $values['integerProperty']; - $this->floatProperty = $values['floatProperty']; - $this->otherFloatProperty = $values['otherFloatProperty']; - $this->booleanProperty = $values['booleanProperty']; - $this->stringProperty = $values['stringProperty']; - $this->intFloatArray = $values['intFloatArray']; - $this->floatArray = $values['floatArray']; - $this->nullableBooleanProperty = $values['nullableBooleanProperty'] ?? null; - } -} - -class ScalarTest extends TestCase -{ - public function testAllScalarTypesIncludingFloat(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'integer_property' => 42, - 'float_property' => 3.14159, - 'other_float_property' => 3, - 'boolean_property' => true, - 'string_property' => 'Hello, World!', - 'int_float_array' => [1, 2.5, 3, 4.75], - 'float_array' => [1, 2, 3, 4] // Ensure we handle "integer-looking" floats - ], - ); - - $object = Scalar::fromJson($expectedJson); - $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); - $this->assertEquals(3.14159, $object->floatProperty, 'float_property should be 3.14159.'); - $this->assertTrue($object->booleanProperty, 'boolean_property should be true.'); - $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); - $this->assertNull($object->nullableBooleanProperty, 'nullable_boolean_property should be null.'); - $this->assertEquals([1, 2.5, 3, 4.75], $object->intFloatArray, 'int_float_array should match the original data.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTest.'); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/TraitTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/TraitTest.php deleted file mode 100644 index e18f06d4191b..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/TraitTest.php +++ /dev/null @@ -1,60 +0,0 @@ -integerProperty = $values['integerProperty']; - $this->stringProperty = $values['stringProperty']; - } -} - -class TraitTest extends TestCase -{ - public function testTraitPropertyAndString(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'integer_property' => 42, - 'string_property' => 'Hello, World!', - ], - ); - - $object = TypeWithTrait::fromJson($expectedJson); - $this->assertEquals(42, $object->integerProperty, 'integer_property should be 42.'); - $this->assertEquals('Hello, World!', $object->stringProperty, 'string_property should be "Hello, World!".'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for ScalarTypesTestWithTrait.'); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionArrayTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionArrayTest.php deleted file mode 100644 index de20cf9fde1b..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionArrayTest.php +++ /dev/null @@ -1,57 +0,0 @@ - $mixedDates - */ - #[ArrayType(['integer' => new Union('datetime', 'string', 'null')])] - #[JsonProperty('mixed_dates')] - public array $mixedDates; - - /** - * @param array{ - * mixedDates: array, - * } $values - */ - public function __construct( - array $values, - ) { - $this->mixedDates = $values['mixedDates']; - } -} - -class UnionArrayTest extends TestCase -{ - public function testUnionArray(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'mixed_dates' => [ - 1 => '2023-01-01T12:00:00Z', - 2 => null, - 3 => 'Some String' - ] - ], - ); - - $object = UnionArray::fromJson($expectedJson); - $this->assertInstanceOf(DateTime::class, $object->mixedDates[1], 'mixed_dates[1] should be a DateTime instance.'); - $this->assertEquals('2023-01-01 12:00:00', $object->mixedDates[1]->format('Y-m-d H:i:s'), 'mixed_dates[1] should have the correct datetime.'); - $this->assertNull($object->mixedDates[2], 'mixed_dates[2] should be null.'); - $this->assertEquals('Some String', $object->mixedDates[3], 'mixed_dates[3] should be "Some String".'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match original JSON for mixed_dates.'); - } -} diff --git a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionPropertyTest.php b/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionPropertyTest.php deleted file mode 100644 index f733062cfabc..000000000000 --- a/seed/php-sdk/basic-auth-pw-omitted/tests/Core/Json/UnionPropertyTest.php +++ /dev/null @@ -1,111 +0,0 @@ - 'integer'], UnionProperty::class)] - #[JsonProperty('complexUnion')] - public mixed $complexUnion; - - /** - * @param array{ - * complexUnion: string|int|null|array|UnionProperty - * } $values - */ - public function __construct( - array $values, - ) { - $this->complexUnion = $values['complexUnion']; - } -} - -class UnionPropertyTest extends TestCase -{ - public function testWithMapOfIntToInt(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'complexUnion' => [1 => 100, 2 => 200] - ], - ); - - $object = UnionProperty::fromJson($expectedJson); - $this->assertIsArray($object->complexUnion, 'complexUnion should be an array.'); - $this->assertEquals([1 => 100, 2 => 200], $object->complexUnion, 'complexUnion should match the original map of int => int.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); - } - - public function testWithNestedUnionPropertyType(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'complexUnion' => new UnionProperty( - [ - 'complexUnion' => 'Nested String' - ] - ) - ], - ); - - $object = UnionProperty::fromJson($expectedJson); - $this->assertInstanceOf(UnionProperty::class, $object->complexUnion, 'complexUnion should be an instance of UnionPropertyType.'); - $this->assertEquals('Nested String', $object->complexUnion->complexUnion, 'Nested complexUnion should match the original value.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); - } - - public function testWithNull(): void - { - $expectedJson = JsonEncoder::encode( - [], - ); - - $object = UnionProperty::fromJson($expectedJson); - $this->assertNull($object->complexUnion, 'complexUnion should be null.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); - } - - public function testWithInteger(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'complexUnion' => 42 - ], - ); - - $object = UnionProperty::fromJson($expectedJson); - $this->assertIsInt($object->complexUnion, 'complexUnion should be an integer.'); - $this->assertEquals(42, $object->complexUnion, 'complexUnion should match the original integer.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); - } - - public function testWithString(): void - { - $expectedJson = JsonEncoder::encode( - [ - 'complexUnion' => 'Some String' - ], - ); - - $object = UnionProperty::fromJson($expectedJson); - $this->assertIsString($object->complexUnion, 'complexUnion should be a string.'); - $this->assertEquals('Some String', $object->complexUnion, 'complexUnion should match the original string.'); - - $actualJson = $object->toJson(); - $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match the original JSON.'); - } -} From 75cff6e37870e5481c646f04fdadde8561aa44ea Mon Sep 17 00:00:00 2001 From: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:36:14 +0000 Subject: [PATCH 24/24] fix(php-sdk): use test-username in wire test setUp to match WireMock mapping expectations Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- generators/php/sdk/src/wire-tests/WireTestGenerator.ts | 2 +- .../wire-tests/tests/Wire/BasicAuthWireTest.php | 2 +- seed/php-sdk/basic-auth/wire-tests/.fern/metadata.json | 2 +- .../basic-auth/wire-tests/tests/Wire/BasicAuthWireTest.php | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/generators/php/sdk/src/wire-tests/WireTestGenerator.ts b/generators/php/sdk/src/wire-tests/WireTestGenerator.ts index 9a2d1d0d5d60..52ad85c39df5 100644 --- a/generators/php/sdk/src/wire-tests/WireTestGenerator.ts +++ b/generators/php/sdk/src/wire-tests/WireTestGenerator.ts @@ -491,7 +491,7 @@ export class WireTestGenerator { }, basic: (basicScheme) => { if (!basicScheme.usernameOmit) { - authParams.push("username: 'test-user'"); + authParams.push("username: 'test-username'"); } if (!basicScheme.passwordOmit) { authParams.push("password: 'test-password'"); diff --git a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php index 67351729872a..0125f21dcc87 100644 --- a/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php +++ b/seed/php-sdk/basic-auth-pw-omitted/wire-tests/tests/Wire/BasicAuthWireTest.php @@ -61,7 +61,7 @@ protected function setUp(): void { parent::setUp(); $wiremockUrl = getenv('WIREMOCK_URL') ?: 'http://localhost:8080'; $this->client = new SeedClient( - username: 'test-user', + username: 'test-username', options: [ 'baseUrl' => $wiremockUrl, ], diff --git a/seed/php-sdk/basic-auth/wire-tests/.fern/metadata.json b/seed/php-sdk/basic-auth/wire-tests/.fern/metadata.json index 8478683b9c41..d6b2eda38969 100644 --- a/seed/php-sdk/basic-auth/wire-tests/.fern/metadata.json +++ b/seed/php-sdk/basic-auth/wire-tests/.fern/metadata.json @@ -1,7 +1,7 @@ { "cliVersion": "DUMMY", "generatorName": "fernapi/fern-php-sdk", - "generatorVersion": "local", + "generatorVersion": "latest", "generatorConfig": { "enable-wire-tests": true }, diff --git a/seed/php-sdk/basic-auth/wire-tests/tests/Wire/BasicAuthWireTest.php b/seed/php-sdk/basic-auth/wire-tests/tests/Wire/BasicAuthWireTest.php index 2d804a182fff..edf399f0571e 100644 --- a/seed/php-sdk/basic-auth/wire-tests/tests/Wire/BasicAuthWireTest.php +++ b/seed/php-sdk/basic-auth/wire-tests/tests/Wire/BasicAuthWireTest.php @@ -61,7 +61,7 @@ protected function setUp(): void { parent::setUp(); $wiremockUrl = getenv('WIREMOCK_URL') ?: 'http://localhost:8080'; $this->client = new SeedClient( - username: 'test-user', + username: 'test-username', password: 'test-password', options: [ 'baseUrl' => $wiremockUrl,