From 16e8cd08cb7b8d1648ea229ca608e8de9b61315c Mon Sep 17 00:00:00 2001 From: "fern-api[bot]" <115122769+fern-api[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:02:15 +0000 Subject: [PATCH] [fern-generated] Update SDK Generated by Fern CLI Version: unknown Generators: - fernapi/fern-php-sdk: 2.10.15 --- .fern/metadata.json | 12 +- .fern/replay.lock | 10 + .fernignore | 3 + .gitattributes | 1 + composer.json | 2 +- reference.md | 473 ++++++++++++++++++ src/Brevo.php | 36 +- src/BrevoInterface.php | 12 + src/ConsentGroups/ConsentGroupsClient.php | 318 ++++++++++++ .../ConsentGroupsClientInterface.php | 102 ++++ .../Requests/CreateConsentGroupRequest.php | 52 ++ .../Requests/GetConsentGroupsRequest.php | 53 ++ .../Requests/UpdateConsentGroupRequest.php | 43 ++ .../CreateConsentGroupRequestSignupMode.php | 9 + .../GetConsentGroupsRequestSignupMode.php | 9 + .../UpdateConsentGroupRequestSignupMode.php | 9 + .../Requests/ImportContactsRequest.php | 8 + src/Contacts/Types/GetContactInfoResponse.php | 9 + ...sUnsubscriptionsUserUnsubscriptionItem.php | 8 +- ...eUnsubscriptionsUserUnsubscriptionItem.php | 8 +- src/Core/Client/HttpMethod.php | 1 + src/Core/Client/RetryDecoratingClient.php | 2 +- src/Core/Json/JsonSerializableType.php | 9 +- src/Core/Json/JsonSerializer.php | 17 +- src/Deals/DealsClient.php | 9 + src/Deals/Requests/GetCrmDealsRequest.php | 21 + .../Types/CreateBatchOrderResponse.php | 18 +- ...sacBlockedContactsResponseContactsItem.php | 8 +- ...PreviewSmtpEmailTemplatesRequestParams.php | 45 -- src/Types/ConsentGroup.php | 95 ++++ src/Types/ConsentGroupItem.php | 45 ++ src/Types/ConsentGroupItemStatus.php | 9 + src/Types/ConsentGroupSignupMode.php | 9 + src/Types/ConsentGroupsListResponse.php | 62 +++ src/Types/GetCampaignStats.php | 90 ++-- src/Types/GetContactDetails.php | 8 + src/Types/WalletPassInstallUrl.php | 34 ++ src/Wallet/WalletClient.php | 100 ++++ src/Wallet/WalletClientInterface.php | 25 + tests/Core/Client/RawClientTest.php | 141 ++++++ tests/Core/Json/EmptyArrayTest.php | 13 +- tests/Core/Json/EmptyObjectTest.php | 409 +++++++++++++++ tests/Core/Json/UnionPropertyTest.php | 4 +- 43 files changed, 2215 insertions(+), 136 deletions(-) create mode 100644 .fern/replay.lock create mode 100644 .gitattributes create mode 100644 src/ConsentGroups/ConsentGroupsClient.php create mode 100644 src/ConsentGroups/ConsentGroupsClientInterface.php create mode 100644 src/ConsentGroups/Requests/CreateConsentGroupRequest.php create mode 100644 src/ConsentGroups/Requests/GetConsentGroupsRequest.php create mode 100644 src/ConsentGroups/Requests/UpdateConsentGroupRequest.php create mode 100644 src/ConsentGroups/Types/CreateConsentGroupRequestSignupMode.php create mode 100644 src/ConsentGroups/Types/GetConsentGroupsRequestSignupMode.php create mode 100644 src/ConsentGroups/Types/UpdateConsentGroupRequestSignupMode.php delete mode 100644 src/TransactionalEmails/Types/PostPreviewSmtpEmailTemplatesRequestParams.php create mode 100644 src/Types/ConsentGroup.php create mode 100644 src/Types/ConsentGroupItem.php create mode 100644 src/Types/ConsentGroupItemStatus.php create mode 100644 src/Types/ConsentGroupSignupMode.php create mode 100644 src/Types/ConsentGroupsListResponse.php create mode 100644 src/Types/WalletPassInstallUrl.php create mode 100644 src/Wallet/WalletClient.php create mode 100644 src/Wallet/WalletClientInterface.php create mode 100644 tests/Core/Json/EmptyObjectTest.php diff --git a/.fern/metadata.json b/.fern/metadata.json index 2a25bf6b..cd746063 100644 --- a/.fern/metadata.json +++ b/.fern/metadata.json @@ -1,7 +1,7 @@ { - "cliVersion": "4.103.1", + "cliVersion": "5.64.3", "generatorName": "fernapi/fern-php-sdk", - "generatorVersion": "2.9.5", + "generatorVersion": "2.10.15", "generatorConfig": { "clientName": "Brevo", "namespace": "Brevo", @@ -20,10 +20,10 @@ "generateClientInterfaces": true, "useDefaultRequestParameterValues": true }, - "originGitCommit": "1934b60b480a426232f9443e7831353a6f965651", - "originGitCommitIsDirty": true, + "originGitCommit": "68600965b5cf1b4868a3929a76f0504425d951ad", + "originGitCommitIsDirty": false, "invokedBy": "manual", - "requestedVersion": "v5.0.0-rc.1", + "requestedVersion": "5.0.1", "ciProvider": null, - "sdkVersion": "v5.0.0-rc.1" + "sdkVersion": "5.0.1" } \ No newline at end of file diff --git a/.fern/replay.lock b/.fern/replay.lock new file mode 100644 index 00000000..5c315671 --- /dev/null +++ b/.fern/replay.lock @@ -0,0 +1,10 @@ +# DO NOT EDIT MANUALLY - Managed by Fern Replay +version: "1.0" +generations: + - commit_sha: 1fc0e8548a25891c7f4373e1bd78b611ca698c18 + tree_hash: 48070f7b186a52de405774f228807fb0877c875c + timestamp: 2026-07-03T12:02:13.744Z + cli_version: unknown + generator_versions: {} +current_generation: 1fc0e8548a25891c7f4373e1bd78b611ca698c18 +patches: [] diff --git a/.fernignore b/.fernignore index 85c18000..bd00b928 100644 --- a/.fernignore +++ b/.fernignore @@ -8,3 +8,6 @@ changelog.md # would never match, causing BrevoApiException in CI. Fixed to use Z without milliseconds. # Protected until Fern fixes the datetime format inconsistency in the PHP generator. wiremock/wiremock-mappings.json +.fern/replay.lock +.fern/replay.yml +.gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..74928d6a --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.fern/replay.lock linguist-generated=true diff --git a/composer.json b/composer.json index 3bda28a9..18e94aca 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "getbrevo/brevo-php", - "version": "v5.0.0-rc.1", + "version": "5.0.1", "description": "Official PHP SDK for the Brevo API.", "keywords": [ "brevo", diff --git a/reference.md b/reference.md index 8e7da029..8cf4ea4a 100644 --- a/reference.md +++ b/reference.md @@ -6615,6 +6615,14 @@ $client->contacts->importContacts(
+**$consentGroupIds:** `?array` — **Optional.** Ids of the consent groups to which all imported contacts will be added. Requires consent groups to be enabled for the organisation. For example, **[1, 3]**. + +
+
+ +
+
+ **$newList:** `?ImportContactsRequestNewList` — To create a new list and import the contacts into it, pass the listName and an optional folderId.
@@ -7614,6 +7622,381 @@ $client->contacts->getContactStats(
+ + + + +## ConsentGroups +
$client->consentGroups->getConsentGroups($request) -> ?ConsentGroupsListResponse +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Returns a paginated list of consent groups for the account. + +This endpoint is only available when the Consent Groups feature is enabled for your account. Returns `403` if the feature is not activated. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->consentGroups->getConsentGroups( + new GetConsentGroupsRequest([]), +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$limit:** `?int` — Maximum number of results to return (default 10, max 50) + +
+
+ +
+
+ +**$offset:** `?int` — Number of results to skip (default 0) + +
+
+ +
+
+ +**$id:** `?int` — Filter by consent group ID + +
+
+ +
+
+ +**$name:** `?string` — Filter by name (case-insensitive partial match) + +
+
+ +
+
+ +**$signupMode:** `?string` — Filter by signup mode + +
+
+
+
+ + +
+
+
+ +
$client->consentGroups->createConsentGroup($request) -> ?ConsentGroup +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Creates a new consent group for the account. + +This endpoint is only available when the Consent Groups feature is enabled for your account. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->consentGroups->createConsentGroup( + new CreateConsentGroupRequest([ + 'name' => 'Newsletter EU', + 'signupMode' => CreateConsentGroupRequestSignupMode::Manual->value, + ]), +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$name:** `string` — Unique name for the consent group (max 255 characters) + +
+
+ +
+
+ +**$description:** `?string` — Optional description (max 500 characters) + +
+
+ +
+
+ +**$signupMode:** `string` — Controls how contacts are added to the group. `manual` — contacts are added explicitly via the API. `automatic` — contacts are added automatically at signup. + +
+
+ +
+
+ +**$listIds:** `?array` — Optional list of contact list IDs. Contacts from these lists will be copied once into this consent group at creation time. + +
+
+
+
+ + +
+
+
+ +
$client->consentGroups->getConsentGroup($id) -> ?ConsentGroup +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Returns a single consent group by ID for the account. + +This endpoint is only available when the Consent Groups feature is enabled for your account. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->consentGroups->getConsentGroup( + 1000000, +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$id:** `int` — ID of the consent group + +
+
+
+
+ + +
+
+
+ +
$client->consentGroups->updateConsentGroup($id, $request) -> ?ConsentGroup +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Updates name, description, or signupMode of a consent group. At least one field must be provided. + +This endpoint is only available when the Consent Groups feature is enabled for your account. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->consentGroups->updateConsentGroup( + 1000000, + new UpdateConsentGroupRequest([]), +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$id:** `int` — ID of the consent group to update + +
+
+ +
+
+ +**$name:** `?string` — New name for the consent group (max 255 characters) + +
+
+ +
+
+ +**$description:** `?string` — New description (max 500 characters) + +
+
+ +
+
+ +**$signupMode:** `?string` — New signup mode + +
+
+
+
+ + +
+
+
+ +
$client->consentGroups->deleteConsentGroup($id) +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Deletes a consent group by ID and removes it from all associated contacts. + +This endpoint is only available when the Consent Groups feature is enabled for your account. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->consentGroups->deleteConsentGroup( + 1000000, +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$id:** `int` — ID of the consent group to delete + +
+
+
+
+ +
@@ -15522,6 +15905,72 @@ $client->tier->deleteTier( + + + + +## Wallet +
$client->wallet->getWalletPassInstallUrl($passId, $contactId) -> ?WalletPassInstallUrl +
+
+ +#### 📝 Description + +
+
+ +
+
+ +Generate a wallet installation URL for a specific contact. The returned URL points to the pass installation page and encodes the pass, contact and organization identifiers as an encrypted token, so it can be shared with the contact (email, SMS, QR code, ...) to add the pass to their Apple Wallet or Google Wallet. +
+
+
+
+ +#### 🔌 Usage + +
+
+ +
+
+ +```php +$client->wallet->getWalletPassInstallUrl( + 'passId', + 1000000, +); +``` +
+
+
+
+ +#### ⚙️ Parameters + +
+
+ +
+
+ +**$passId:** `string` — Pass ID. The unique identifier of the wallet pass for which to generate an installation URL. + +
+
+ +
+
+ +**$contactId:** `int` — The Brevo contact ID the installation URL is generated for. + +
+
+
+
+ +
@@ -19310,6 +19759,30 @@ $client->deals->getAllDeals(
+**$filtersAttributesDealOwner:** `?string` — Filter by the deal owner. Pass the account email address of the deal owner. + +
+
+ +
+
+ +**$filtersAttributesDealStage:** `?string` — Filter by the deal stage. Pass the stage id, retrievable from GET /crm/pipeline/details/{pipelineID}. + +
+
+ +
+
+ +**$filtersAttributesPipeline:** `?string` — Filter by the pipeline. Pass the pipeline id, retrievable from GET /crm/pipeline/details/{pipelineID}. + +
+
+ +
+
+ **$filtersLinkedCompaniesIds:** `?string` — Filter by linked companies ids
diff --git a/src/Brevo.php b/src/Brevo.php index d9ce593e..a69847a6 100644 --- a/src/Brevo.php +++ b/src/Brevo.php @@ -12,6 +12,7 @@ use Brevo\ExternalFeeds\ExternalFeedsClient; use Brevo\CustomObjects\CustomObjectsClient; use Brevo\Contacts\ContactsClient; +use Brevo\ConsentGroups\ConsentGroupsClient; use Brevo\Conversations\ConversationsClient; use Brevo\Ecommerce\EcommerceClient; use Brevo\Coupons\CouponsClient; @@ -22,6 +23,7 @@ use Brevo\Program\ProgramClient; use Brevo\Reward\RewardClient; use Brevo\Tier\TierClient; +use Brevo\Wallet\WalletClient; use Brevo\EmailCampaigns\EmailCampaignsClient; use Brevo\SmsCampaigns\SmsCampaignsClient; use Brevo\WhatsAppCampaigns\WhatsAppCampaignsClient; @@ -46,6 +48,7 @@ use Brevo\ExternalFeeds\ExternalFeedsClientInterface; use Brevo\CustomObjects\CustomObjectsClientInterface; use Brevo\Contacts\ContactsClientInterface; +use Brevo\ConsentGroups\ConsentGroupsClientInterface; use Brevo\Conversations\ConversationsClientInterface; use Brevo\Ecommerce\EcommerceClientInterface; use Brevo\Coupons\CouponsClientInterface; @@ -56,6 +59,7 @@ use Brevo\Program\ProgramClientInterface; use Brevo\Reward\RewardClientInterface; use Brevo\Tier\TierClientInterface; +use Brevo\Wallet\WalletClientInterface; use Brevo\EmailCampaigns\EmailCampaignsClientInterface; use Brevo\SmsCampaigns\SmsCampaignsClientInterface; use Brevo\WhatsAppCampaigns\WhatsAppCampaignsClientInterface; @@ -121,6 +125,11 @@ class Brevo implements BrevoInterface */ public ContactsClient $contacts; + /** + * @var ConsentGroupsClient $consentGroups + */ + public ConsentGroupsClient $consentGroups; + /** * @var ConversationsClient $conversations */ @@ -171,6 +180,11 @@ class Brevo implements BrevoInterface */ public TierClient $tier; + /** + * @var WalletClient $wallet + */ + public WalletClient $wallet; + /** * @var EmailCampaignsClient $emailCampaigns */ @@ -265,8 +279,8 @@ public function __construct( 'api-key' => $apiKey, 'X-Fern-Language' => 'PHP', 'X-Fern-SDK-Name' => 'Brevo', - 'X-Fern-SDK-Version' => 'v5.0.0-rc.1', - 'User-Agent' => 'getbrevo/brevo-php/v5.0.0-rc.1', + 'X-Fern-SDK-Version' => '5.0.1', + 'User-Agent' => 'getbrevo/brevo-php/5.0.1', ]; $this->options = $options ?? []; @@ -290,6 +304,7 @@ public function __construct( $this->externalFeeds = new ExternalFeedsClient($this->client, $this->options); $this->customObjects = new CustomObjectsClient($this->client, $this->options); $this->contacts = new ContactsClient($this->client, $this->options); + $this->consentGroups = new ConsentGroupsClient($this->client, $this->options); $this->conversations = new ConversationsClient($this->client, $this->options); $this->ecommerce = new EcommerceClient($this->client, $this->options); $this->coupons = new CouponsClient($this->client, $this->options); @@ -300,6 +315,7 @@ public function __construct( $this->program = new ProgramClient($this->client, $this->options); $this->reward = new RewardClient($this->client, $this->options); $this->tier = new TierClient($this->client, $this->options); + $this->wallet = new WalletClient($this->client, $this->options); $this->emailCampaigns = new EmailCampaignsClient($this->client, $this->options); $this->smsCampaigns = new SmsCampaignsClient($this->client, $this->options); $this->whatsAppCampaigns = new WhatsAppCampaignsClient($this->client, $this->options); @@ -394,6 +410,14 @@ public function getContacts(): ContactsClientInterface return $this->contacts; } + /** + * @return ConsentGroupsClientInterface + */ + public function getConsentGroups(): ConsentGroupsClientInterface + { + return $this->consentGroups; + } + /** * @return ConversationsClientInterface */ @@ -474,6 +498,14 @@ public function getTier(): TierClientInterface return $this->tier; } + /** + * @return WalletClientInterface + */ + public function getWallet(): WalletClientInterface + { + return $this->wallet; + } + /** * @return EmailCampaignsClientInterface */ diff --git a/src/BrevoInterface.php b/src/BrevoInterface.php index 7c58cc2c..e10cd94a 100644 --- a/src/BrevoInterface.php +++ b/src/BrevoInterface.php @@ -12,6 +12,7 @@ use Brevo\ExternalFeeds\ExternalFeedsClientInterface; use Brevo\CustomObjects\CustomObjectsClientInterface; use Brevo\Contacts\ContactsClientInterface; +use Brevo\ConsentGroups\ConsentGroupsClientInterface; use Brevo\Conversations\ConversationsClientInterface; use Brevo\Ecommerce\EcommerceClientInterface; use Brevo\Coupons\CouponsClientInterface; @@ -22,6 +23,7 @@ use Brevo\Program\ProgramClientInterface; use Brevo\Reward\RewardClientInterface; use Brevo\Tier\TierClientInterface; +use Brevo\Wallet\WalletClientInterface; use Brevo\EmailCampaigns\EmailCampaignsClientInterface; use Brevo\SmsCampaigns\SmsCampaignsClientInterface; use Brevo\WhatsAppCampaigns\WhatsAppCampaignsClientInterface; @@ -87,6 +89,11 @@ public function getCustomObjects(): CustomObjectsClientInterface; */ public function getContacts(): ContactsClientInterface; + /** + * @return ConsentGroupsClientInterface + */ + public function getConsentGroups(): ConsentGroupsClientInterface; + /** * @return ConversationsClientInterface */ @@ -137,6 +144,11 @@ public function getReward(): RewardClientInterface; */ public function getTier(): TierClientInterface; + /** + * @return WalletClientInterface + */ + public function getWallet(): WalletClientInterface; + /** * @return EmailCampaignsClientInterface */ diff --git a/src/ConsentGroups/ConsentGroupsClient.php b/src/ConsentGroups/ConsentGroupsClient.php new file mode 100644 index 00000000..8a5986c8 --- /dev/null +++ b/src/ConsentGroups/ConsentGroupsClient.php @@ -0,0 +1,318 @@ +, + * } $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 ?? []; + } + + /** + * Returns a paginated list of consent groups for the account. + * + * This endpoint is only available when the Consent Groups feature is enabled for your account. Returns `403` if the feature is not activated. + * + * @param GetConsentGroupsRequest $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?ConsentGroupsListResponse + * @throws BrevoException + * @throws BrevoApiException + */ + public function getConsentGroups(GetConsentGroupsRequest $request = new GetConsentGroupsRequest(), ?array $options = null): ?ConsentGroupsListResponse + { + $options = array_merge($this->options, $options ?? []); + $query = []; + if ($request->limit != null) { + $query['limit'] = $request->limit; + } + if ($request->offset != null) { + $query['offset'] = $request->offset; + } + if ($request->id != null) { + $query['id'] = $request->id; + } + if ($request->name != null) { + $query['name'] = $request->name; + } + if ($request->signupMode != null) { + $query['signupMode'] = $request->signupMode; + } + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "contacts/consent-groups", + method: HttpMethod::GET, + query: $query, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return ConsentGroupsListResponse::fromJson($json); + } + } catch (JsonException $e) { + throw new BrevoException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new BrevoException(message: $e->getMessage(), previous: $e); + } + throw new BrevoApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } + + /** + * Creates a new consent group for the account. + * + * This endpoint is only available when the Consent Groups feature is enabled for your account. + * + * @param CreateConsentGroupRequest $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?ConsentGroup + * @throws BrevoException + * @throws BrevoApiException + */ + public function createConsentGroup(CreateConsentGroupRequest $request, ?array $options = null): ?ConsentGroup + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "contacts/consent-groups", + method: HttpMethod::POST, + body: $request, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return ConsentGroup::fromJson($json); + } + } catch (JsonException $e) { + throw new BrevoException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new BrevoException(message: $e->getMessage(), previous: $e); + } + throw new BrevoApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } + + /** + * Returns a single consent group by ID for the account. + * + * This endpoint is only available when the Consent Groups feature is enabled for your account. + * + * @param int $id ID of the consent group + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?ConsentGroup + * @throws BrevoException + * @throws BrevoApiException + */ + public function getConsentGroup(int $id, ?array $options = null): ?ConsentGroup + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "contacts/consent-groups/{$id}", + method: HttpMethod::GET, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return ConsentGroup::fromJson($json); + } + } catch (JsonException $e) { + throw new BrevoException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new BrevoException(message: $e->getMessage(), previous: $e); + } + throw new BrevoApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } + + /** + * Updates name, description, or signupMode of a consent group. At least one field must be provided. + * + * This endpoint is only available when the Consent Groups feature is enabled for your account. + * + * @param int $id ID of the consent group to update + * @param UpdateConsentGroupRequest $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?ConsentGroup + * @throws BrevoException + * @throws BrevoApiException + */ + public function updateConsentGroup(int $id, UpdateConsentGroupRequest $request = new UpdateConsentGroupRequest(), ?array $options = null): ?ConsentGroup + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "contacts/consent-groups/{$id}", + method: HttpMethod::PUT, + body: $request, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return ConsentGroup::fromJson($json); + } + } catch (JsonException $e) { + throw new BrevoException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new BrevoException(message: $e->getMessage(), previous: $e); + } + throw new BrevoApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } + + /** + * Deletes a consent group by ID and removes it from all associated contacts. + * + * This endpoint is only available when the Consent Groups feature is enabled for your account. + * + * @param int $id ID of the consent group to delete + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @throws BrevoException + * @throws BrevoApiException + */ + public function deleteConsentGroup(int $id, ?array $options = null): void + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "contacts/consent-groups/{$id}", + method: HttpMethod::DELETE, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + return; + } + } catch (ClientExceptionInterface $e) { + throw new BrevoException(message: $e->getMessage(), previous: $e); + } + throw new BrevoApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } +} diff --git a/src/ConsentGroups/ConsentGroupsClientInterface.php b/src/ConsentGroups/ConsentGroupsClientInterface.php new file mode 100644 index 00000000..f2b52407 --- /dev/null +++ b/src/ConsentGroups/ConsentGroupsClientInterface.php @@ -0,0 +1,102 @@ +This endpoint is only available when the Consent Groups feature is enabled for your account. Returns `403` if the feature is not activated. + * + * @param GetConsentGroupsRequest $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?ConsentGroupsListResponse + */ + public function getConsentGroups(GetConsentGroupsRequest $request = new GetConsentGroupsRequest(), ?array $options = null): ?ConsentGroupsListResponse; + + /** + * Creates a new consent group for the account. + * + * This endpoint is only available when the Consent Groups feature is enabled for your account. + * + * @param CreateConsentGroupRequest $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?ConsentGroup + */ + public function createConsentGroup(CreateConsentGroupRequest $request, ?array $options = null): ?ConsentGroup; + + /** + * Returns a single consent group by ID for the account. + * + * This endpoint is only available when the Consent Groups feature is enabled for your account. + * + * @param int $id ID of the consent group + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?ConsentGroup + */ + public function getConsentGroup(int $id, ?array $options = null): ?ConsentGroup; + + /** + * Updates name, description, or signupMode of a consent group. At least one field must be provided. + * + * This endpoint is only available when the Consent Groups feature is enabled for your account. + * + * @param int $id ID of the consent group to update + * @param UpdateConsentGroupRequest $request + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?ConsentGroup + */ + public function updateConsentGroup(int $id, UpdateConsentGroupRequest $request = new UpdateConsentGroupRequest(), ?array $options = null): ?ConsentGroup; + + /** + * Deletes a consent group by ID and removes it from all associated contacts. + * + * This endpoint is only available when the Consent Groups feature is enabled for your account. + * + * @param int $id ID of the consent group to delete + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + */ + public function deleteConsentGroup(int $id, ?array $options = null): void; +} diff --git a/src/ConsentGroups/Requests/CreateConsentGroupRequest.php b/src/ConsentGroups/Requests/CreateConsentGroupRequest.php new file mode 100644 index 00000000..35b27cfa --- /dev/null +++ b/src/ConsentGroups/Requests/CreateConsentGroupRequest.php @@ -0,0 +1,52 @@ + $signupMode Controls how contacts are added to the group. `manual` — contacts are added explicitly via the API. `automatic` — contacts are added automatically at signup. + */ + #[JsonProperty('signupMode')] + public string $signupMode; + + /** + * @var ?array $listIds Optional list of contact list IDs. Contacts from these lists will be copied once into this consent group at creation time. + */ + #[JsonProperty('listIds'), ArrayType(['integer'])] + public ?array $listIds; + + /** + * @param array{ + * name: string, + * signupMode: value-of, + * description?: ?string, + * listIds?: ?array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->name = $values['name']; + $this->description = $values['description'] ?? null; + $this->signupMode = $values['signupMode']; + $this->listIds = $values['listIds'] ?? null; + } +} diff --git a/src/ConsentGroups/Requests/GetConsentGroupsRequest.php b/src/ConsentGroups/Requests/GetConsentGroupsRequest.php new file mode 100644 index 00000000..911d7b74 --- /dev/null +++ b/src/ConsentGroups/Requests/GetConsentGroupsRequest.php @@ -0,0 +1,53 @@ + $signupMode Filter by signup mode + */ + public ?string $signupMode; + + /** + * @param array{ + * limit?: ?int, + * offset?: ?int, + * id?: ?int, + * name?: ?string, + * signupMode?: ?value-of, + * } $values + */ + public function __construct( + array $values = [], + ) { + $this->limit = $values['limit'] ?? null; + $this->offset = $values['offset'] ?? null; + $this->id = $values['id'] ?? null; + $this->name = $values['name'] ?? null; + $this->signupMode = $values['signupMode'] ?? null; + } +} diff --git a/src/ConsentGroups/Requests/UpdateConsentGroupRequest.php b/src/ConsentGroups/Requests/UpdateConsentGroupRequest.php new file mode 100644 index 00000000..aa7ac196 --- /dev/null +++ b/src/ConsentGroups/Requests/UpdateConsentGroupRequest.php @@ -0,0 +1,43 @@ + $signupMode New signup mode + */ + #[JsonProperty('signupMode')] + public ?string $signupMode; + + /** + * @param array{ + * name?: ?string, + * description?: ?string, + * signupMode?: ?value-of, + * } $values + */ + public function __construct( + array $values = [], + ) { + $this->name = $values['name'] ?? null; + $this->description = $values['description'] ?? null; + $this->signupMode = $values['signupMode'] ?? null; + } +} diff --git a/src/ConsentGroups/Types/CreateConsentGroupRequestSignupMode.php b/src/ConsentGroups/Types/CreateConsentGroupRequestSignupMode.php new file mode 100644 index 00000000..1cbf3d41 --- /dev/null +++ b/src/ConsentGroups/Types/CreateConsentGroupRequestSignupMode.php @@ -0,0 +1,9 @@ + $consentGroupIds **Optional.** Ids of the consent groups to which all imported contacts will be added. Requires consent groups to be enabled for the organisation. For example, **[1, 3]**. + */ + #[JsonProperty('consentGroupIds'), ArrayType(['integer'])] + public ?array $consentGroupIds; + /** * @var ?ImportContactsRequestNewList $newList To create a new list and import the contacts into it, pass the listName and an optional folderId. */ @@ -85,6 +91,7 @@ class ImportContactsRequest extends JsonSerializableType * fileUrl?: ?string, * jsonBody?: ?array, * listIds?: ?array, + * consentGroupIds?: ?array, * newList?: ?ImportContactsRequestNewList, * notifyUrl?: ?string, * smsBlacklist?: ?bool, @@ -101,6 +108,7 @@ public function __construct( $this->fileUrl = $values['fileUrl'] ?? null; $this->jsonBody = $values['jsonBody'] ?? null; $this->listIds = $values['listIds'] ?? null; + $this->consentGroupIds = $values['consentGroupIds'] ?? null; $this->newList = $values['newList'] ?? null; $this->notifyUrl = $values['notifyUrl'] ?? null; $this->smsBlacklist = $values['smsBlacklist'] ?? null; diff --git a/src/Contacts/Types/GetContactInfoResponse.php b/src/Contacts/Types/GetContactInfoResponse.php index 6e78d37c..0b1625b3 100644 --- a/src/Contacts/Types/GetContactInfoResponse.php +++ b/src/Contacts/Types/GetContactInfoResponse.php @@ -5,6 +5,7 @@ use Brevo\Core\Json\JsonSerializableType; use Brevo\Core\Json\JsonProperty; use Brevo\Core\Types\ArrayType; +use Brevo\Types\ConsentGroupItem; class GetContactInfoResponse extends JsonSerializableType { @@ -68,6 +69,12 @@ class GetContactInfoResponse extends JsonSerializableType #[JsonProperty('whatsappBlacklisted')] public bool $whatsappBlacklisted; + /** + * @var ?array $consentGroups Consent groups the contact belongs to, with their subscription status. Only present when the Consent Groups feature is enabled for your account. + */ + #[JsonProperty('consentGroups'), ArrayType([ConsentGroupItem::class])] + public ?array $consentGroups; + /** * @var GetContactInfoResponseStatistics $statistics Campaign statistics of the contact */ @@ -87,6 +94,7 @@ class GetContactInfoResponse extends JsonSerializableType * statistics: GetContactInfoResponseStatistics, * email?: ?string, * listUnsubscribed?: ?array, + * consentGroups?: ?array, * } $values */ public function __construct( @@ -102,6 +110,7 @@ public function __construct( $this->modifiedAt = $values['modifiedAt']; $this->smsBlacklisted = $values['smsBlacklisted']; $this->whatsappBlacklisted = $values['whatsappBlacklisted']; + $this->consentGroups = $values['consentGroups'] ?? null; $this->statistics = $values['statistics']; } diff --git a/src/Contacts/Types/GetContactInfoResponseStatisticsUnsubscriptionsUserUnsubscriptionItem.php b/src/Contacts/Types/GetContactInfoResponseStatisticsUnsubscriptionsUserUnsubscriptionItem.php index d20c8f18..b7f6ca47 100644 --- a/src/Contacts/Types/GetContactInfoResponseStatisticsUnsubscriptionsUserUnsubscriptionItem.php +++ b/src/Contacts/Types/GetContactInfoResponseStatisticsUnsubscriptionsUserUnsubscriptionItem.php @@ -8,10 +8,10 @@ class GetContactInfoResponseStatisticsUnsubscriptionsUserUnsubscriptionItem extends JsonSerializableType { /** - * @var int $campaignId ID of the campaign which generated the event + * @var ?int $campaignId ID of the campaign which generated the event */ #[JsonProperty('campaignId')] - public int $campaignId; + public ?int $campaignId; /** * @var string $eventTime UTC date-time of the event @@ -27,15 +27,15 @@ class GetContactInfoResponseStatisticsUnsubscriptionsUserUnsubscriptionItem exte /** * @param array{ - * campaignId: int, * eventTime: string, + * campaignId?: ?int, * ip?: ?string, * } $values */ public function __construct( array $values, ) { - $this->campaignId = $values['campaignId']; + $this->campaignId = $values['campaignId'] ?? null; $this->eventTime = $values['eventTime']; $this->ip = $values['ip'] ?? null; } diff --git a/src/Contacts/Types/GetContactStatsResponseUnsubscriptionsUserUnsubscriptionItem.php b/src/Contacts/Types/GetContactStatsResponseUnsubscriptionsUserUnsubscriptionItem.php index ee417fce..7ad073d4 100644 --- a/src/Contacts/Types/GetContactStatsResponseUnsubscriptionsUserUnsubscriptionItem.php +++ b/src/Contacts/Types/GetContactStatsResponseUnsubscriptionsUserUnsubscriptionItem.php @@ -8,10 +8,10 @@ class GetContactStatsResponseUnsubscriptionsUserUnsubscriptionItem extends JsonSerializableType { /** - * @var int $campaignId ID of the campaign which generated the event + * @var ?int $campaignId ID of the campaign which generated the event */ #[JsonProperty('campaignId')] - public int $campaignId; + public ?int $campaignId; /** * @var string $eventTime UTC date-time of the event @@ -27,15 +27,15 @@ class GetContactStatsResponseUnsubscriptionsUserUnsubscriptionItem extends JsonS /** * @param array{ - * campaignId: int, * eventTime: string, + * campaignId?: ?int, * ip?: ?string, * } $values */ public function __construct( array $values, ) { - $this->campaignId = $values['campaignId']; + $this->campaignId = $values['campaignId'] ?? null; $this->eventTime = $values['eventTime']; $this->ip = $values['ip'] ?? null; } diff --git a/src/Core/Client/HttpMethod.php b/src/Core/Client/HttpMethod.php index 0d90869b..e630fd16 100644 --- a/src/Core/Client/HttpMethod.php +++ b/src/Core/Client/HttpMethod.php @@ -9,4 +9,5 @@ enum HttpMethod case PUT; case PATCH; case DELETE; + case HEAD; } diff --git a/src/Core/Client/RetryDecoratingClient.php b/src/Core/Client/RetryDecoratingClient.php index dfe03a5d..d30fad82 100644 --- a/src/Core/Client/RetryDecoratingClient.php +++ b/src/Core/Client/RetryDecoratingClient.php @@ -108,7 +108,7 @@ private function doSend(RequestInterface $request, ?float $timeout): ResponseInt return $this->client->sendRequest($request); } - if (class_exists('GuzzleHttp\ClientInterface') + if (interface_exists('GuzzleHttp\ClientInterface') && $this->client instanceof \GuzzleHttp\ClientInterface ) { return $this->client->send($request, ['timeout' => $timeout]); diff --git a/src/Core/Json/JsonSerializableType.php b/src/Core/Json/JsonSerializableType.php index 2d7e66ff..f938523e 100644 --- a/src/Core/Json/JsonSerializableType.php +++ b/src/Core/Json/JsonSerializableType.php @@ -31,7 +31,7 @@ abstract class JsonSerializableType implements \JsonSerializable public function toJson(): string { $serializedObject = $this->jsonSerialize(); - $encoded = JsonEncoder::encode($serializedObject); + $encoded = JsonEncoder::encode(empty($serializedObject) ? new \stdClass() : $serializedObject); if (!$encoded) { throw new Exception("Could not encode type"); } @@ -78,8 +78,8 @@ public function jsonSerialize(): array $value = JsonSerializer::serializeArray($value, $arrayType); } - // Handle object - if (is_object($value)) { + // Handle object (skip stdClass since it's already serialized, e.g. from union processing) + if (is_object($value) && !($value instanceof \stdClass)) { $value = JsonSerializer::serializeObject($value); } @@ -106,7 +106,8 @@ public static function fromJson(string $json): static throw new JsonException("Unexpected non-array decoded type: " . gettype($decodedJson)); } /** @var array $decodedJson */ - return self::jsonDeserialize($decodedJson); + // static:: (not self::) so subclasses' typed jsonDeserialize overrides are dispatched via late static binding. + return static::jsonDeserialize($decodedJson); } /** diff --git a/src/Core/Json/JsonSerializer.php b/src/Core/Json/JsonSerializer.php index a8b0268e..11a9cd8c 100644 --- a/src/Core/Json/JsonSerializer.php +++ b/src/Core/Json/JsonSerializer.php @@ -43,10 +43,10 @@ public static function serializeDateTime(DateTime $date): string * * @param array $data The array to be serialized. * @param array $type The type definition from the annotation. - * @return array The serialized array. + * @return array|\stdClass The serialized array, or stdClass if empty map. * @throws JsonException If serialization fails. */ - public static function serializeArray(array $data, array $type): array + public static function serializeArray(array $data, array $type): array|\stdClass { return Utils::isMapType($type) ? self::serializeMap($data, $type) @@ -158,7 +158,11 @@ public static function serializeObject(object $data): mixed $type = get_class($data); throw new JsonException("Class $type must implement JsonSerializable."); } - return $data->jsonSerialize(); + $result = $data->jsonSerialize(); + if (is_array($result) && empty($result)) { + return new \stdClass(); + } + return $result; } /** @@ -166,11 +170,14 @@ public static function serializeObject(object $data): mixed * * @param array $data The associative array to serialize. * @param array $type The type definition for the map. - * @return array The serialized map. + * @return array|\stdClass The serialized map, or stdClass if empty. * @throws JsonException If serialization fails. */ - private static function serializeMap(array $data, array $type): array + private static function serializeMap(array $data, array $type): array|\stdClass { + if (empty($data)) { + return new \stdClass(); + } $keyType = array_key_first($type); if ($keyType === null) { throw new JsonException("Unexpected no key in ArrayType."); diff --git a/src/Deals/DealsClient.php b/src/Deals/DealsClient.php index b7be2d9e..ae75a90e 100644 --- a/src/Deals/DealsClient.php +++ b/src/Deals/DealsClient.php @@ -133,6 +133,15 @@ public function getAllDeals(GetCrmDealsRequest $request = new GetCrmDealsRequest if ($request->filtersAttributesDealName != null) { $query['filters[attributes.deal_name]'] = $request->filtersAttributesDealName; } + if ($request->filtersAttributesDealOwner != null) { + $query['filters[attributes.deal_owner]'] = $request->filtersAttributesDealOwner; + } + if ($request->filtersAttributesDealStage != null) { + $query['filters[attributes.deal_stage]'] = $request->filtersAttributesDealStage; + } + if ($request->filtersAttributesPipeline != null) { + $query['filters[attributes.pipeline]'] = $request->filtersAttributesPipeline; + } if ($request->filtersLinkedCompaniesIds != null) { $query['filters[linkedCompaniesIds]'] = $request->filtersLinkedCompaniesIds; } diff --git a/src/Deals/Requests/GetCrmDealsRequest.php b/src/Deals/Requests/GetCrmDealsRequest.php index 85290314..ce63b16b 100644 --- a/src/Deals/Requests/GetCrmDealsRequest.php +++ b/src/Deals/Requests/GetCrmDealsRequest.php @@ -12,6 +12,21 @@ class GetCrmDealsRequest extends JsonSerializableType */ public ?string $filtersAttributesDealName; + /** + * @var ?string $filtersAttributesDealOwner Filter by the deal owner. Pass the account email address of the deal owner. + */ + public ?string $filtersAttributesDealOwner; + + /** + * @var ?string $filtersAttributesDealStage Filter by the deal stage. Pass the stage id, retrievable from GET /crm/pipeline/details/{pipelineID}. + */ + public ?string $filtersAttributesDealStage; + + /** + * @var ?string $filtersAttributesPipeline Filter by the pipeline. Pass the pipeline id, retrievable from GET /crm/pipeline/details/{pipelineID}. + */ + public ?string $filtersAttributesPipeline; + /** * @var ?string $filtersLinkedCompaniesIds Filter by linked companies ids */ @@ -55,6 +70,9 @@ class GetCrmDealsRequest extends JsonSerializableType /** * @param array{ * filtersAttributesDealName?: ?string, + * filtersAttributesDealOwner?: ?string, + * filtersAttributesDealStage?: ?string, + * filtersAttributesPipeline?: ?string, * filtersLinkedCompaniesIds?: ?string, * filtersLinkedContactsIds?: ?string, * modifiedSince?: ?string, @@ -69,6 +87,9 @@ public function __construct( array $values = [], ) { $this->filtersAttributesDealName = $values['filtersAttributesDealName'] ?? null; + $this->filtersAttributesDealOwner = $values['filtersAttributesDealOwner'] ?? null; + $this->filtersAttributesDealStage = $values['filtersAttributesDealStage'] ?? null; + $this->filtersAttributesPipeline = $values['filtersAttributesPipeline'] ?? null; $this->filtersLinkedCompaniesIds = $values['filtersLinkedCompaniesIds'] ?? null; $this->filtersLinkedContactsIds = $values['filtersLinkedContactsIds'] ?? null; $this->modifiedSince = $values['modifiedSince'] ?? null; diff --git a/src/Ecommerce/Types/CreateBatchOrderResponse.php b/src/Ecommerce/Types/CreateBatchOrderResponse.php index bfbf821e..0fb60435 100644 --- a/src/Ecommerce/Types/CreateBatchOrderResponse.php +++ b/src/Ecommerce/Types/CreateBatchOrderResponse.php @@ -7,29 +7,29 @@ class CreateBatchOrderResponse extends JsonSerializableType { - /** - * @var float $batchId Batch ID of the request - */ - #[JsonProperty('batchId')] - public float $batchId; - /** * @var ?int $count Number of orders */ #[JsonProperty('count')] public ?int $count; + /** + * @var ?float $batchId Batch ID of the request + */ + #[JsonProperty('batch_id')] + public ?float $batchId; + /** * @param array{ - * batchId: float, * count?: ?int, + * batchId?: ?float, * } $values */ public function __construct( - array $values, + array $values = [], ) { - $this->batchId = $values['batchId']; $this->count = $values['count'] ?? null; + $this->batchId = $values['batchId'] ?? null; } /** diff --git a/src/TransactionalEmails/Types/GetTransacBlockedContactsResponseContactsItem.php b/src/TransactionalEmails/Types/GetTransacBlockedContactsResponseContactsItem.php index d193cf2f..e0623cf1 100644 --- a/src/TransactionalEmails/Types/GetTransacBlockedContactsResponseContactsItem.php +++ b/src/TransactionalEmails/Types/GetTransacBlockedContactsResponseContactsItem.php @@ -26,17 +26,17 @@ class GetTransacBlockedContactsResponseContactsItem extends JsonSerializableType public GetTransacBlockedContactsResponseContactsItemReason $reason; /** - * @var string $senderEmail Sender email address of the blocked or unsubscribed contact + * @var ?string $senderEmail Sender email address of the blocked or unsubscribed contact */ #[JsonProperty('senderEmail')] - public string $senderEmail; + public ?string $senderEmail; /** * @param array{ * blockedAt: string, * email: string, * reason: GetTransacBlockedContactsResponseContactsItemReason, - * senderEmail: string, + * senderEmail?: ?string, * } $values */ public function __construct( @@ -45,7 +45,7 @@ public function __construct( $this->blockedAt = $values['blockedAt']; $this->email = $values['email']; $this->reason = $values['reason']; - $this->senderEmail = $values['senderEmail']; + $this->senderEmail = $values['senderEmail'] ?? null; } /** diff --git a/src/TransactionalEmails/Types/PostPreviewSmtpEmailTemplatesRequestParams.php b/src/TransactionalEmails/Types/PostPreviewSmtpEmailTemplatesRequestParams.php deleted file mode 100644 index d7f42ad7..00000000 --- a/src/TransactionalEmails/Types/PostPreviewSmtpEmailTemplatesRequestParams.php +++ /dev/null @@ -1,45 +0,0 @@ -firstname = $values['firstname'] ?? null; - $this->lastname = $values['lastname'] ?? null; - } - - /** - * @return string - */ - public function __toString(): string - { - return $this->toJson(); - } -} diff --git a/src/Types/ConsentGroup.php b/src/Types/ConsentGroup.php new file mode 100644 index 00000000..ca23ed37 --- /dev/null +++ b/src/Types/ConsentGroup.php @@ -0,0 +1,95 @@ + $signupMode Controls how contacts are added to the group. `manual` — contacts are added explicitly via the API. `automatic` — contacts are added automatically at signup. + */ + #[JsonProperty('signupMode')] + public string $signupMode; + + /** + * @var int $contactsCount Number of contacts currently in this consent group + */ + #[JsonProperty('contactsCount')] + public int $contactsCount; + + /** + * @var bool $isDefault Whether this is a system-created default consent group + */ + #[JsonProperty('isDefault')] + public bool $isDefault; + + /** + * @var DateTime $createdAt UTC date-time when the consent group was created (YYYY-MM-DDTHH:mm:ss.SSSZ) + */ + #[JsonProperty('createdAt'), Date(Date::TYPE_DATETIME)] + public DateTime $createdAt; + + /** + * @var DateTime $updatedAt UTC date-time when the consent group was last updated (YYYY-MM-DDTHH:mm:ss.SSSZ) + */ + #[JsonProperty('updatedAt'), Date(Date::TYPE_DATETIME)] + public DateTime $updatedAt; + + /** + * @param array{ + * id: int, + * name: string, + * signupMode: value-of, + * contactsCount: int, + * isDefault: bool, + * createdAt: DateTime, + * updatedAt: DateTime, + * description?: ?string, + * } $values + */ + public function __construct( + array $values, + ) { + $this->id = $values['id']; + $this->name = $values['name']; + $this->description = $values['description'] ?? null; + $this->signupMode = $values['signupMode']; + $this->contactsCount = $values['contactsCount']; + $this->isDefault = $values['isDefault']; + $this->createdAt = $values['createdAt']; + $this->updatedAt = $values['updatedAt']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/src/Types/ConsentGroupItem.php b/src/Types/ConsentGroupItem.php new file mode 100644 index 00000000..c359ffc7 --- /dev/null +++ b/src/Types/ConsentGroupItem.php @@ -0,0 +1,45 @@ + $status The contact's subscription status within this consent group + */ + #[JsonProperty('status')] + public string $status; + + /** + * @param array{ + * id: int, + * status: value-of, + * } $values + */ + public function __construct( + array $values, + ) { + $this->id = $values['id']; + $this->status = $values['status']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/src/Types/ConsentGroupItemStatus.php b/src/Types/ConsentGroupItemStatus.php new file mode 100644 index 00000000..d04444c7 --- /dev/null +++ b/src/Types/ConsentGroupItemStatus.php @@ -0,0 +1,9 @@ + $consentGroups List of consent groups matching the filter + */ + #[JsonProperty('consentGroups'), ArrayType([ConsentGroup::class])] + public array $consentGroups; + + /** + * @var int $total Total number of consent groups matching the filter + */ + #[JsonProperty('total')] + public int $total; + + /** + * @var int $limit Maximum number of results returned + */ + #[JsonProperty('limit')] + public int $limit; + + /** + * @var int $offset Number of results skipped + */ + #[JsonProperty('offset')] + public int $offset; + + /** + * @param array{ + * consentGroups: array, + * total: int, + * limit: int, + * offset: int, + * } $values + */ + public function __construct( + array $values, + ) { + $this->consentGroups = $values['consentGroups']; + $this->total = $values['total']; + $this->limit = $values['limit']; + $this->offset = $values['offset']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/src/Types/GetCampaignStats.php b/src/Types/GetCampaignStats.php index eceb7811..87e8f7c4 100644 --- a/src/Types/GetCampaignStats.php +++ b/src/Types/GetCampaignStats.php @@ -14,16 +14,16 @@ class GetCampaignStats extends JsonSerializableType public ?int $appleMppOpens; /** - * @var int $clickers Number of total clicks for the campaign + * @var ?int $clickers Number of total clicks for the campaign */ #[JsonProperty('clickers')] - public int $clickers; + public ?int $clickers; /** - * @var int $complaints Number of complaints (Spam reports) for the campaign + * @var ?int $complaints Number of complaints (Spam reports) for the campaign */ #[JsonProperty('complaints')] - public int $complaints; + public ?int $complaints; /** * @var ?int $deferred Number of deferred emails for the campaign @@ -32,10 +32,10 @@ class GetCampaignStats extends JsonSerializableType public ?int $deferred; /** - * @var int $delivered Number of delivered emails for the campaign + * @var ?int $delivered Number of delivered emails for the campaign */ #[JsonProperty('delivered')] - public int $delivered; + public ?int $delivered; /** * @var ?int $estimatedViews Rate of recipients without any privacy protection option enabled in their email client, applied to all delivered emails @@ -44,10 +44,10 @@ class GetCampaignStats extends JsonSerializableType public ?int $estimatedViews; /** - * @var int $hardBounces Number of hard bounces for the campaign + * @var ?int $hardBounces Number of hard bounces for the campaign */ #[JsonProperty('hardBounces')] - public int $hardBounces; + public ?int $hardBounces; /** * @var ?int $listId List Id of email campaign (only in case of get email campaign(s)(not for global stats)) @@ -68,22 +68,22 @@ class GetCampaignStats extends JsonSerializableType public ?int $returnBounce; /** - * @var int $sent Number of sent emails for the campaign + * @var ?int $sent Number of sent emails for the campaign */ #[JsonProperty('sent')] - public int $sent; + public ?int $sent; /** - * @var int $softBounces Number of softbounce for the campaign + * @var ?int $softBounces Number of softbounce for the campaign */ #[JsonProperty('softBounces')] - public int $softBounces; + public ?int $softBounces; /** - * @var int $trackableViews Recipients without any privacy protection option enabled in their email email client + * @var ?int $trackableViews Recipients without any privacy protection option enabled in their email email client */ #[JsonProperty('trackableViews')] - public int $trackableViews; + public ?int $trackableViews; /** * @var ?float $trackableViewsRate Rate of recipients without any privacy protection option enabled in their email client @@ -92,72 +92,72 @@ class GetCampaignStats extends JsonSerializableType public ?float $trackableViewsRate; /** - * @var int $uniqueClicks Number of unique clicks for the campaign + * @var ?int $uniqueClicks Number of unique clicks for the campaign */ #[JsonProperty('uniqueClicks')] - public int $uniqueClicks; + public ?int $uniqueClicks; /** - * @var int $uniqueViews Number of unique openings for the campaign + * @var ?int $uniqueViews Number of unique openings for the campaign */ #[JsonProperty('uniqueViews')] - public int $uniqueViews; + public ?int $uniqueViews; /** - * @var int $unsubscriptions Number of unsubscription for the campaign + * @var ?int $unsubscriptions Number of unsubscription for the campaign */ #[JsonProperty('unsubscriptions')] - public int $unsubscriptions; + public ?int $unsubscriptions; /** - * @var int $viewed Number of openings for the campaign + * @var ?int $viewed Number of openings for the campaign */ #[JsonProperty('viewed')] - public int $viewed; + public ?int $viewed; /** * @param array{ - * clickers: int, - * complaints: int, - * delivered: int, - * hardBounces: int, - * sent: int, - * softBounces: int, - * trackableViews: int, - * uniqueClicks: int, - * uniqueViews: int, - * unsubscriptions: int, - * viewed: int, * appleMppOpens?: ?int, + * clickers?: ?int, + * complaints?: ?int, * deferred?: ?int, + * delivered?: ?int, * estimatedViews?: ?int, + * hardBounces?: ?int, * listId?: ?int, * opensRate?: ?float, * returnBounce?: ?int, + * sent?: ?int, + * softBounces?: ?int, + * trackableViews?: ?int, * trackableViewsRate?: ?float, + * uniqueClicks?: ?int, + * uniqueViews?: ?int, + * unsubscriptions?: ?int, + * viewed?: ?int, * } $values */ public function __construct( - array $values, + array $values = [], ) { $this->appleMppOpens = $values['appleMppOpens'] ?? null; - $this->clickers = $values['clickers']; - $this->complaints = $values['complaints']; + $this->clickers = $values['clickers'] ?? null; + $this->complaints = $values['complaints'] ?? null; $this->deferred = $values['deferred'] ?? null; - $this->delivered = $values['delivered']; + $this->delivered = $values['delivered'] ?? null; $this->estimatedViews = $values['estimatedViews'] ?? null; - $this->hardBounces = $values['hardBounces']; + $this->hardBounces = $values['hardBounces'] ?? null; $this->listId = $values['listId'] ?? null; $this->opensRate = $values['opensRate'] ?? null; $this->returnBounce = $values['returnBounce'] ?? null; - $this->sent = $values['sent']; - $this->softBounces = $values['softBounces']; - $this->trackableViews = $values['trackableViews']; + $this->sent = $values['sent'] ?? null; + $this->softBounces = $values['softBounces'] ?? null; + $this->trackableViews = $values['trackableViews'] ?? null; $this->trackableViewsRate = $values['trackableViewsRate'] ?? null; - $this->uniqueClicks = $values['uniqueClicks']; - $this->uniqueViews = $values['uniqueViews']; - $this->unsubscriptions = $values['unsubscriptions']; - $this->viewed = $values['viewed']; + $this->uniqueClicks = $values['uniqueClicks'] ?? null; + $this->uniqueViews = $values['uniqueViews'] ?? null; + $this->unsubscriptions = $values['unsubscriptions'] ?? null; + $this->viewed = $values['viewed'] ?? null; } /** diff --git a/src/Types/GetContactDetails.php b/src/Types/GetContactDetails.php index bc849296..add7261b 100644 --- a/src/Types/GetContactDetails.php +++ b/src/Types/GetContactDetails.php @@ -68,6 +68,12 @@ class GetContactDetails extends JsonSerializableType #[JsonProperty('whatsappBlacklisted')] public bool $whatsappBlacklisted; + /** + * @var ?array $consentGroups Consent groups the contact belongs to, with their subscription status. Only present when the Consent Groups feature is enabled for your account. + */ + #[JsonProperty('consentGroups'), ArrayType([ConsentGroupItem::class])] + public ?array $consentGroups; + /** * @param array{ * attributes: GetContactDetailsAttributes, @@ -80,6 +86,7 @@ class GetContactDetails extends JsonSerializableType * whatsappBlacklisted: bool, * email?: ?string, * listUnsubscribed?: ?array, + * consentGroups?: ?array, * } $values */ public function __construct( @@ -95,6 +102,7 @@ public function __construct( $this->modifiedAt = $values['modifiedAt']; $this->smsBlacklisted = $values['smsBlacklisted']; $this->whatsappBlacklisted = $values['whatsappBlacklisted']; + $this->consentGroups = $values['consentGroups'] ?? null; } /** diff --git a/src/Types/WalletPassInstallUrl.php b/src/Types/WalletPassInstallUrl.php new file mode 100644 index 00000000..666d1232 --- /dev/null +++ b/src/Types/WalletPassInstallUrl.php @@ -0,0 +1,34 @@ +url = $values['url']; + } + + /** + * @return string + */ + public function __toString(): string + { + return $this->toJson(); + } +} diff --git a/src/Wallet/WalletClient.php b/src/Wallet/WalletClient.php new file mode 100644 index 00000000..fe58f7e6 --- /dev/null +++ b/src/Wallet/WalletClient.php @@ -0,0 +1,100 @@ +, + * } $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 ?? []; + } + + /** + * Generate a wallet installation URL for a specific contact. The returned URL points to the pass installation page and encodes the pass, contact and organization identifiers as an encrypted token, so it can be shared with the contact (email, SMS, QR code, ...) to add the pass to their Apple Wallet or Google Wallet. + * + * @param string $passId Pass ID. The unique identifier of the wallet pass for which to generate an installation URL. + * @param int $contactId The Brevo contact ID the installation URL is generated for. + * @param ?array{ + * baseUrl?: string, + * maxRetries?: int, + * timeout?: float, + * headers?: array, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?WalletPassInstallUrl + * @throws BrevoException + * @throws BrevoApiException + */ + public function getWalletPassInstallUrl(string $passId, int $contactId, ?array $options = null): ?WalletPassInstallUrl + { + $options = array_merge($this->options, $options ?? []); + try { + $response = $this->client->sendRequest( + new JsonApiRequest( + baseUrl: $options['baseUrl'] ?? $this->client->options['baseUrl'] ?? Environments::Default_->value, + path: "wallet/passes/{$passId}/installUrl/{$contactId}", + method: HttpMethod::GET, + ), + $options, + ); + $statusCode = $response->getStatusCode(); + if ($statusCode >= 200 && $statusCode < 400) { + $json = $response->getBody()->getContents(); + if (empty($json)) { + return null; + } + return WalletPassInstallUrl::fromJson($json); + } + } catch (JsonException $e) { + throw new BrevoException(message: "Failed to deserialize response: {$e->getMessage()}", previous: $e); + } catch (ClientExceptionInterface $e) { + throw new BrevoException(message: $e->getMessage(), previous: $e); + } + throw new BrevoApiException( + message: 'API request failed', + statusCode: $statusCode, + body: $response->getBody()->getContents(), + ); + } +} diff --git a/src/Wallet/WalletClientInterface.php b/src/Wallet/WalletClientInterface.php new file mode 100644 index 00000000..917dc521 --- /dev/null +++ b/src/Wallet/WalletClientInterface.php @@ -0,0 +1,25 @@ +, + * queryParameters?: array, + * bodyProperties?: array, + * } $options + * @return ?WalletPassInstallUrl + */ + public function getWalletPassInstallUrl(string $passId, int $contactId, ?array $options = null): ?WalletPassInstallUrl; +} diff --git a/tests/Core/Client/RawClientTest.php b/tests/Core/Client/RawClientTest.php index 3e7168b9..a91392bd 100644 --- a/tests/Core/Client/RawClientTest.php +++ b/tests/Core/Client/RawClientTest.php @@ -1071,4 +1071,145 @@ public function testDiscoveryFindsFactories(): void $stream = $streamFactory->createStream('hello'); $this->assertEquals('hello', (string) $stream); } + + public function testInterfaceExistsDetectsGuzzle(): void + { + $this->assertTrue( + interface_exists('GuzzleHttp\ClientInterface'), + 'interface_exists should detect GuzzleHttp\ClientInterface when Guzzle is installed', + ); + } + + public function testTimeoutForwardsToGuzzleSend(): void + { + $expectedResponse = self::createResponse(200); + + $guzzleClient = new class ($expectedResponse) implements \Psr\Http\Client\ClientInterface, \GuzzleHttp\ClientInterface { + private ResponseInterface $response; + /** @var array */ + public array $lastOptions = []; + + public function __construct(ResponseInterface $response) + { + $this->response = $response; + } + + /** @param array $options */ + public function send(\Psr\Http\Message\RequestInterface $request, array $options = []): ResponseInterface + { + $this->lastOptions = $options; + return $this->response; + } + + /** @param array $options */ + public function sendAsync(\Psr\Http\Message\RequestInterface $request, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \RuntimeException('Not implemented'); + } + + /** @param array $options */ + public function request(string $method, $uri, array $options = []): ResponseInterface + { + throw new \RuntimeException('Not implemented'); + } + + /** @param array $options */ + public function requestAsync(string $method, $uri, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \RuntimeException('Not implemented'); + } + + public function getConfig(?string $option = null) + { + return null; + } + + public function sendRequest(\Psr\Http\Message\RequestInterface $request): ResponseInterface + { + return $this->response; + } + }; + + $retryClient = new RetryDecoratingClient( + $guzzleClient, + maxRetries: 0, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->send($request, timeout: 5.0); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertArrayHasKey('timeout', $guzzleClient->lastOptions); + $this->assertEquals(5.0, $guzzleClient->lastOptions['timeout']); + } + + public function testNoTimeoutDoesNotCallGuzzleSend(): void + { + $expectedResponse = self::createResponse(200); + + $guzzleClient = new class ($expectedResponse) implements \Psr\Http\Client\ClientInterface, \GuzzleHttp\ClientInterface { + private ResponseInterface $response; + public bool $sendCalled = false; + + public function __construct(ResponseInterface $response) + { + $this->response = $response; + } + + /** @param array $options */ + public function send(\Psr\Http\Message\RequestInterface $request, array $options = []): ResponseInterface + { + $this->sendCalled = true; + return $this->response; + } + + /** @param array $options */ + public function sendAsync(\Psr\Http\Message\RequestInterface $request, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \RuntimeException('Not implemented'); + } + + /** @param array $options */ + public function request(string $method, $uri, array $options = []): ResponseInterface + { + throw new \RuntimeException('Not implemented'); + } + + /** @param array $options */ + public function requestAsync(string $method, $uri, array $options = []): \GuzzleHttp\Promise\PromiseInterface + { + throw new \RuntimeException('Not implemented'); + } + + public function getConfig(?string $option = null) + { + return null; + } + + public function sendRequest(\Psr\Http\Message\RequestInterface $request): ResponseInterface + { + return $this->response; + } + }; + + $retryClient = new RetryDecoratingClient( + $guzzleClient, + maxRetries: 0, + sleepFunction: function (int $_microseconds): void { + }, + ); + + $requestFactory = \Http\Discovery\Psr17FactoryDiscovery::findRequestFactory(); + $request = $requestFactory->createRequest('GET', $this->baseUrl . '/test'); + + $response = $retryClient->send($request, timeout: null); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertFalse($guzzleClient->sendCalled, 'Guzzle send() should not be called when timeout is null'); + } + } diff --git a/tests/Core/Json/EmptyArrayTest.php b/tests/Core/Json/EmptyArrayTest.php index 9691414a..3b02706d 100644 --- a/tests/Core/Json/EmptyArrayTest.php +++ b/tests/Core/Json/EmptyArrayTest.php @@ -52,7 +52,7 @@ class EmptyArrayTest extends TestCase { public function testEmptyArray(): void { - $expectedJson = JsonEncoder::encode( + $inputJson = JsonEncoder::encode( [ 'empty_string_array' => [], 'empty_map_array' => [], @@ -60,12 +60,19 @@ public function testEmptyArray(): void ], ); - $object = EmptyArray::fromJson($expectedJson); + $object = EmptyArray::fromJson($inputJson); $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.'); + $expectedJson = JsonEncoder::encode( + [ + 'empty_string_array' => [], + 'empty_map_array' => new \stdClass(), + 'empty_dates_array' => [] + ], + ); + $this->assertJsonStringEqualsJsonString($expectedJson, $actualJson, 'Serialized JSON does not match expected JSON for EmptyArraysType.'); } } diff --git a/tests/Core/Json/EmptyObjectTest.php b/tests/Core/Json/EmptyObjectTest.php new file mode 100644 index 00000000..3bda2f00 --- /dev/null +++ b/tests/Core/Json/EmptyObjectTest.php @@ -0,0 +1,409 @@ +optionalField = $values['optionalField'] ?? null; + } +} + +class EmptyObjectWithNestedObject extends JsonSerializableType +{ + /** + * @var string $name + */ + #[JsonProperty('name')] + public string $name; + + /** + * @var EmptyObject $nested + */ + #[JsonProperty('nested')] + public EmptyObject $nested; + + /** + * @param array{ + * name: string, + * nested: EmptyObject, + * } $values + */ + public function __construct( + array $values, + ) { + $this->name = $values['name']; + $this->nested = $values['nested']; + } +} + +class ObjectWithEmptyMap extends JsonSerializableType +{ + /** + * @var array $metadata + */ + #[JsonProperty('metadata'), ArrayType(['string' => 'string'])] + public array $metadata; + + /** + * @param array{ + * metadata: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->metadata = $values['metadata']; + } +} + +class ObjectWithListAndMap extends JsonSerializableType +{ + /** + * @var string[] $list + */ + #[ArrayType(['string'])] + #[JsonProperty('list')] + public array $list; + + /** + * @var array $map + */ + #[ArrayType(['string' => 'string'])] + #[JsonProperty('map')] + public array $map; + + /** + * @param array{ + * list: string[], + * map: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->list = $values['list']; + $this->map = $values['map']; + } +} + +class EmptyUnionMember extends JsonSerializableType +{ + /** + * @var string|null $tag + */ + #[JsonProperty('tag')] + public ?string $tag; + + /** + * @param array{ + * tag?: string|null, + * } $values + */ + public function __construct( + array $values = [], + ) { + $this->tag = $values['tag'] ?? null; + } +} + +class ObjectWithUnionEmpty extends JsonSerializableType +{ + /** + * @var string|EmptyUnionMember|null $value + */ + #[Union('string', EmptyUnionMember::class, 'null')] + #[JsonProperty('value')] + public mixed $value; + + /** + * @param array{ + * value: string|EmptyUnionMember|null, + * } $values + */ + public function __construct( + array $values, + ) { + $this->value = $values['value']; + } +} + +class ObjectWithListOfMaps extends JsonSerializableType +{ + /** + * @var array> $items + */ + #[ArrayType([['string' => 'string']])] + #[JsonProperty('items')] + public array $items; + + /** + * @param array{ + * items: array>, + * } $values + */ + public function __construct( + array $values, + ) { + $this->items = $values['items']; + } +} + +class ObjectWithMapOfObjects extends JsonSerializableType +{ + /** + * @var array $entries + */ + #[ArrayType(['string' => EmptyUnionMember::class])] + #[JsonProperty('entries')] + public array $entries; + + /** + * @param array{ + * entries: array, + * } $values + */ + public function __construct( + array $values, + ) { + $this->entries = $values['entries']; + } +} + +class ObjectWithAdditionalOnly extends JsonSerializableType +{ + /** + * @var string|null $name + */ + #[JsonProperty('name')] + public ?string $name; + + /** + * @param array{ + * name?: string|null, + * } $values + */ + public function __construct( + array $values = [], + ) { + $this->name = $values['name'] ?? null; + } +} + +class EmptyObjectTest extends TestCase +{ + public function testEmptyObjectSerializesToObject(): void + { + $object = new EmptyObject([]); + $json = $object->toJson(); + $this->assertEquals('{}', $json, 'Empty object should serialize to {} not [].'); + } + + public function testEmptyObjectWithFieldSetSerializesCorrectly(): void + { + $object = new EmptyObject(['optionalField' => 'value']); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString('{"optional_field": "value"}', $json); + } + + public function testNestedEmptyObjectSerializesToObject(): void + { + $parent = new EmptyObjectWithNestedObject([ + 'name' => 'test', + 'nested' => new EmptyObject([]), + ]); + $json = $parent->toJson(); + $expected = '{"name": "test", "nested": {}}'; + $this->assertJsonStringEqualsJsonString($expected, $json, 'Nested empty object should serialize to {} not [].'); + } + + public function testDeserializeEmptyObject(): void + { + $json = '{}'; + $object = EmptyObject::fromJson($json); + $this->assertNull($object->optionalField); + $this->assertEquals('{}', $object->toJson(), 'Deserialized empty object should re-serialize to {}.'); + } + + public function testEmptyMapSerializesToObject(): void + { + $object = new ObjectWithEmptyMap(['metadata' => []]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString('{"metadata":{}}', $json, 'Empty map should serialize to {} not [].'); + } + + public function testNonEmptyMapSerializesCorrectly(): void + { + $object = new ObjectWithEmptyMap(['metadata' => ['key' => 'value']]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString('{"metadata":{"key":"value"}}', $json); + } + + public function testEmptyListAndEmptyMapSideBySide(): void + { + $object = new ObjectWithListAndMap(['list' => [], 'map' => []]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"list":[],"map":{}}', + $json, + 'Empty list should serialize as [] and empty map as {} on the same object.' + ); + } + + public function testNonEmptyListAndEmptyMap(): void + { + $object = new ObjectWithListAndMap(['list' => ['a', 'b'], 'map' => []]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"list":["a","b"],"map":{}}', + $json + ); + } + + public function testEmptyListAndNonEmptyMap(): void + { + $object = new ObjectWithListAndMap(['list' => [], 'map' => ['key' => 'val']]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"list":[],"map":{"key":"val"}}', + $json + ); + } + + public function testUnionWithEmptyObject(): void + { + $object = new ObjectWithUnionEmpty(['value' => new EmptyUnionMember([])]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"value":{}}', + $json, + 'Union containing an empty object should serialize to {} via the stdClass guard path.' + ); + } + + public function testUnionWithNonEmptyObject(): void + { + $object = new ObjectWithUnionEmpty(['value' => new EmptyUnionMember(['tag' => 'hello'])]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"value":{"tag":"hello"}}', + $json + ); + } + + public function testUnionWithStringFallback(): void + { + $object = new ObjectWithUnionEmpty(['value' => 'plain string']); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"value":"plain string"}', + $json + ); + } + + public function testListOfEmptyMaps(): void + { + $object = new ObjectWithListOfMaps(['items' => [[], []]]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"items":[{},{}]}', + $json, + 'A list of empty maps should produce [{},{}] — each empty map is {} but the list stays [].' + ); + } + + public function testListOfMixedMaps(): void + { + $object = new ObjectWithListOfMaps(['items' => [['a' => 'b'], [], ['c' => 'd']]]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"items":[{"a":"b"},{},{"c":"d"}]}', + $json + ); + } + + public function testMapOfEmptyObjects(): void + { + $object = new ObjectWithMapOfObjects([ + 'entries' => [ + 'first' => new EmptyUnionMember([]), + 'second' => new EmptyUnionMember([]), + ] + ]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"entries":{"first":{},"second":{}}}', + $json, + 'A map whose values are empty objects should produce {"a":{},"b":{}}.' + ); + } + + public function testMapOfMixedObjects(): void + { + $object = new ObjectWithMapOfObjects([ + 'entries' => [ + 'empty' => new EmptyUnionMember([]), + 'filled' => new EmptyUnionMember(['tag' => 'present']), + ] + ]); + $json = $object->toJson(); + $this->assertJsonStringEqualsJsonString( + '{"entries":{"empty":{},"filled":{"tag":"present"}}}', + $json + ); + } + + public function testEmptyObjectWithAdditionalPropertiesOnly(): void + { + $json = '{"extra_key":"extra_value"}'; + $object = ObjectWithAdditionalOnly::fromJson($json); + $this->assertNull($object->name); + $this->assertEquals(['extra_key' => 'extra_value'], $object->getAdditionalProperties()); + + $reserialized = $object->toJson(); + $this->assertEquals('{}', $reserialized, 'Object with only additional properties (no declared fields set) should serialize to {}.'); + } + + public function testEmptyObjectNoAdditionalProperties(): void + { + $object = new ObjectWithAdditionalOnly([]); + $json = $object->toJson(); + $this->assertEquals('{}', $json, 'Object with all null properties and no additional properties should serialize to {}.'); + } + + public function testRoundTripListAndMap(): void + { + $inputJson = '{"list":[],"map":{}}'; + $object = ObjectWithListAndMap::fromJson($inputJson); + $this->assertEmpty($object->list); + $this->assertEmpty($object->map); + $this->assertJsonStringEqualsJsonString($inputJson, $object->toJson()); + } + + public function testRoundTripUnionWithEmptyObject(): void + { + $inputJson = '{"value":{}}'; + $object = ObjectWithUnionEmpty::fromJson($inputJson); + $this->assertInstanceOf(EmptyUnionMember::class, $object->value); + $this->assertJsonStringEqualsJsonString($inputJson, $object->toJson()); + } +} diff --git a/tests/Core/Json/UnionPropertyTest.php b/tests/Core/Json/UnionPropertyTest.php index 5be32d81..7331c87c 100644 --- a/tests/Core/Json/UnionPropertyTest.php +++ b/tests/Core/Json/UnionPropertyTest.php @@ -66,9 +66,7 @@ public function testWithNestedUnionPropertyType(): void public function testWithNull(): void { - $expectedJson = JsonEncoder::encode( - [], - ); + $expectedJson = '{}'; $object = UnionProperty::fromJson($expectedJson); $this->assertNull($object->complexUnion, 'complexUnion should be null.');