Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"require": {
"php": "^8.0",
"ext-curl": "*",
"ext-intl": "*",
"ext-json": "*",
"ext-openssl": "*",
"composer/ca-bundle": "^1.3"
Expand Down
3 changes: 2 additions & 1 deletion src/API/Endpoints/BaseEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use Vatly\API\Resources\Links\LinksResourceFactory;
use Vatly\API\Resources\Links\PaginationLinks;
use Vatly\API\Resources\ResourceFactory;
use Vatly\API\Support\IdnEmail;
use Vatly\API\VatlyApiClient;

abstract class BaseEndpoint
Expand Down Expand Up @@ -228,7 +229,7 @@ protected function parseRequestBody(array $body): ?string
return null;
}

return @json_encode($body);
return @json_encode(IdnEmail::normalizePayload($body));
}

/**
Expand Down
11 changes: 5 additions & 6 deletions src/API/Endpoints/OrderEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,11 @@ public function requestAddressUpdateLink(string $id, array $data = []): Link

$resource = "{$this->getResourcePath()}/" . urlencode($id) . "/request-address-update-link";

$body = null;
if (count($data) > 0) {
$body = json_encode($data);
}

$result = $this->client->performHttpCall(self::REST_CREATE, $resource, $body);
$result = $this->client->performHttpCall(
self::REST_CREATE,
$resource,
$this->parseRequestBody($data),
);

return new Link($result->href, $result->type);
}
Expand Down
11 changes: 5 additions & 6 deletions src/API/Endpoints/SubscriptionEndpoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,11 @@ public function updateBilling(string $subscriptionId, array $data = []): Link

$resource = "{$this->getResourcePath()}/" . urlencode($subscriptionId) . "/update-billing";

$body = null;
if (count($data) > 0) {
$body = json_encode($data);
}

$result = $this->client->performHttpCall(self::REST_UPDATE, $resource, $body);
$result = $this->client->performHttpCall(
self::REST_UPDATE,
$resource,
$this->parseRequestBody($data),
);

return new Link($result->href, $result->type);
}
Expand Down
74 changes: 74 additions & 0 deletions src/API/Support/IdnEmail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace Vatly\API\Support;

class IdnEmail
{
/**
* Convert the domain part of an email to its Punycode (ASCII) form.
*
* The local-part is returned unchanged: there is no equivalent ASCII
* encoding for non-ASCII local-parts, and the Vatly API does not
* support EAI / SMTPUTF8 (see openapi.yaml).
*
* Fail-open: if the input has no '@' or idn_to_ascii fails, return
* the original value so the caller sees the server's validation error
* rather than a silently mangled address.
*/
public static function toAscii(string $email): string
{
$at = strrpos($email, '@');
if ($at === false) {
return $email;
}

$local = substr($email, 0, $at);
$domain = substr($email, $at + 1);

if ($domain === '') {
return $email;
}

$asciiDomain = idn_to_ascii($domain, IDNA_NONTRANSITIONAL_TO_ASCII, INTL_IDNA_VARIANT_UTS46);
if ($asciiDomain === false) {
return $email;
}

return $local . '@' . $asciiDomain;
}

/**
* Walk a request payload and normalize any string value at a key named
* 'email' to its Punycode (ASCII) form. Recurses into nested arrays.
*
* The `metadata` key is treated as opaque: the OpenAPI spec defines it
* as arbitrary application-defined key/value data, so the SDK must not
* rewrite values nested inside it (e.g. a caller storing their own
* `metadata.email` key should get it through to the API untouched).
*
* @param array<int|string, mixed> $payload
* @return array<int|string, mixed>
*/
public static function normalizePayload(array $payload): array
{
foreach ($payload as $key => $value) {
if ($key === 'metadata') {
continue;
}

if (is_array($value)) {
$payload[$key] = self::normalizePayload($value);

continue;
}

if ($key === 'email' && is_string($value)) {
$payload[$key] = self::toAscii($value);
}
}

return $payload;
}
}
31 changes: 31 additions & 0 deletions tests/Endpoints/CustomersEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,37 @@ public function it_creates_customer_with_minimal_data(): void
$this->assertNull($customer->metadata);
}

/** @test */
public function it_punycodes_the_email_domain_before_sending(): void
{
$responseBodyArray = [
'id' => 'customer_78b146a7de7d417e9d68d7e6ef193d18',
'resource' => 'customer',
'email' => 'user@xn--mnchen-3ya.de',
'createdAt' => '2020-01-01T00:00:00+00:00',
'testmode' => true,
'links' => [
'self' => [
'href' => self::API_ENDPOINT_URL . '/customers/customer_78b146a7de7d417e9d68d7e6ef193d18',
'type' => 'application/hal+json',
],
],
];

$this->httpClient->setSendReturnObjectFromArray($responseBodyArray);

$this->client->customers->create([
'email' => 'user@münchen.de',
]);

$this->assertWasSentOnly(
VatlyApiClient::HTTP_POST,
self::API_ENDPOINT_URL . '/customers',
[],
'{"email":"user@xn--mnchen-3ya.de"}'
);
}

/** @test */
public function it_can_get_a_customer(): void
{
Expand Down
23 changes: 23 additions & 0 deletions tests/Endpoints/OrderEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -287,4 +287,27 @@ public function can_request_address_update_link(): void
);
$this->assertEquals(self::API_ENDPOINT_URL."/customer/invoices/$orderId?editable=true&signature=1234567890abcdef", $response->href);
}

/** @test */
public function request_address_update_link_punycodes_email_domains_in_billing_address(): void
{
$orderId = 'order_dummy_id';
$this->httpClient->setSendReturnObjectFromArray([
'href' => self::API_ENDPOINT_URL."/customer/invoices/$orderId?editable=true&signature=1234567890abcdef",
'type' => 'text/html',
]);

$this->client->orders->requestAddressUpdateLink($orderId, [
'billingAddress' => [
'email' => 'billing@müller.de',
],
]);

$this->assertWasSentOnly(
VatlyApiClient::HTTP_POST,
self::API_ENDPOINT_URL.'/orders/'.$orderId.'/request-address-update-link',
[],
'{"billingAddress":{"email":"billing@xn--mller-kva.de"}}'
);
}
}
23 changes: 23 additions & 0 deletions tests/Endpoints/SubscriptionEndpointTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,29 @@ public function can_update_billing_details()
$this->assertEquals(self::WEBSITE_ENDPOINT_URL.'/checkout/checkout_dummy_id/update', $response->href);
}

/** @test */
public function update_billing_punycodes_email_domains_in_billing_address(): void
{
$this->httpClient->setSendReturnObjectFromArray([
'href' => self::WEBSITE_ENDPOINT_URL.'/checkout/checkout_dummy_id/update',
'type' => 'text/html',
]);

$this->client->subscriptions->updateBilling('subscription_123', [
'billingAddress' => [
'email' => 'billing@müller.de',
'city' => 'München',
],
]);

$this->assertWasSentOnly(
VatlyApiClient::HTTP_PATCH,
self::API_ENDPOINT_URL.'/subscriptions/subscription_123/update-billing',
[],
'{"billingAddress":{"email":"billing@xn--mller-kva.de","city":"München"}}'
);
}

/** @test */
public function can_update_subscription_quantity()
{
Expand Down
117 changes: 117 additions & 0 deletions tests/Support/IdnEmailTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<?php

declare(strict_types=1);

namespace Vatly\Tests\Support;

use PHPUnit\Framework\TestCase;
use Vatly\API\Support\IdnEmail;

class IdnEmailTest extends TestCase
{
/** @test */
public function it_passes_ascii_addresses_through_unchanged(): void
{
$this->assertSame('user@example.com', IdnEmail::toAscii('user@example.com'));
}

/** @test */
public function it_converts_idn_domain_to_punycode(): void
{
$this->assertSame('user@xn--mnchen-3ya.de', IdnEmail::toAscii('user@münchen.de'));
$this->assertSame('billing@xn--mller-kva.de', IdnEmail::toAscii('billing@müller.de'));
}

/** @test */
public function it_returns_input_unchanged_when_at_sign_is_missing(): void
{
$this->assertSame('not-an-email', IdnEmail::toAscii('not-an-email'));
$this->assertSame('', IdnEmail::toAscii(''));
}

/** @test */
public function it_returns_input_unchanged_when_conversion_fails(): void
{
// Empty domain — idn_to_ascii returns false on this. Fail-open: return original.
$this->assertSame('user@', IdnEmail::toAscii('user@'));
}

/** @test */
public function it_splits_on_the_last_at_sign(): void
{
// Quoted local-parts may contain '@'; we should split on the rightmost one.
$this->assertSame('"foo@bar"@xn--mnchen-3ya.de', IdnEmail::toAscii('"foo@bar"@münchen.de'));
}

/** @test */
public function it_leaves_unicode_local_part_untouched(): void
{
// No standardized ASCII encoding for non-ASCII local-parts. Server rejects
// these per the API spec; the SDK should not silently transform them.
$this->assertSame('jösé@xn--mnchen-3ya.de', IdnEmail::toAscii('jösé@münchen.de'));
}

/** @test */
public function normalize_payload_walks_nested_structures(): void
{
$input = [
'email' => 'user@münchen.de',
'billingAddress' => [
'email' => 'billing@müller.de',
'city' => 'München',
],
'metadata' => [
'order_id' => '123',
],
];

$expected = [
'email' => 'user@xn--mnchen-3ya.de',
'billingAddress' => [
'email' => 'billing@xn--mller-kva.de',
'city' => 'München',
],
'metadata' => [
'order_id' => '123',
],
];

$this->assertSame($expected, IdnEmail::normalizePayload($input));
}

/** @test */
public function normalize_payload_only_touches_string_email_values(): void
{
$input = [
'email' => null,
'inner' => ['email' => 12345],
];

$this->assertSame($input, IdnEmail::normalizePayload($input));
}

/** @test */
public function normalize_payload_leaves_metadata_untouched(): void
{
// `metadata` is opaque application-defined data per the OpenAPI spec.
// The SDK must not rewrite values nested inside it, even when a caller
// happens to use a key named `email` for their own purposes.
$input = [
'email' => 'user@müller.de',
'metadata' => [
'email' => 'tracker+user@müller.de',
'nested' => ['email' => 'should-also-be-left-alone@münchen.de'],
],
];

$expected = [
'email' => 'user@xn--mller-kva.de',
'metadata' => [
'email' => 'tracker+user@müller.de',
'nested' => ['email' => 'should-also-be-left-alone@münchen.de'],
],
];

$this->assertSame($expected, IdnEmail::normalizePayload($input));
}
}