From a5819c9545f40336516cfceed47b2dc84cbb5233 Mon Sep 17 00:00:00 2001 From: Silas Sewell Date: Sun, 18 Feb 2024 20:08:28 -0500 Subject: [PATCH] Add webhook support (#6) --- .editorconfig | 16 + .github/workflows/test.yaml | 8 +- .php-cs-fixer.dist.php => .php-cs-fixer.php | 3 + Makefile | 15 + README.md | 18 +- composer.json | 1 + {example => examples/api}/main.php | 4 +- examples/webhook/main.php | 44 ++ lib/AdminApi/Flows.php | 28 +- lib/AdminApi/Invoices.php | 14 +- lib/AdminApi/Organizations.php | 60 +-- lib/AdminApi/Subscriptions.php | 16 +- lib/AdminApi/Users.php | 54 +-- lib/AdminV1/AccountConnection.php | 2 +- lib/AdminV1/AccountSubscription.php | 2 +- lib/AdminV1/AccountSubscriptionPlan.php | 2 +- lib/AdminV1/AccountSubscriptionSeat.php | 2 +- lib/AdminV1/CardPaymentMethod.php | 2 +- lib/AdminV1/Connection.php | 14 +- lib/AdminV1/Event.php | 14 +- lib/AdminV1/Flow.php | 10 +- lib/AdminV1/Invoice.php | 10 +- lib/AdminV1/InvoiceAccount.php | 2 +- lib/AdminV1/InvoiceItem.php | 6 +- lib/AdminV1/InvoicePreview.php | 4 +- lib/AdminV1/InvoicePreviewItem.php | 6 +- lib/AdminV1/Member.php | 6 +- lib/AdminV1/Membership.php | 6 +- lib/AdminV1/Organization.php | 4 +- lib/AdminV1/OrganizationInput.php | 2 +- lib/AdminV1/OrganizationResult.php | 4 +- lib/AdminV1/PaymentIntent.php | 2 +- lib/AdminV1/PaymentMethod.php | 4 +- lib/AdminV1/Plan.php | 2 +- lib/AdminV1/PlanGroup.php | 4 +- lib/AdminV1/PlanGroupChangePath.php | 2 +- lib/AdminV1/PlanGroupRevisionItem.php | 2 +- lib/AdminV1/PlanGroupRevisionPlan.php | 4 +- lib/AdminV1/PlanItem.php | 4 +- lib/AdminV1/PostmarkConnection.php | 4 +- lib/AdminV1/Price.php | 10 +- lib/AdminV1/PriceFixedPrice.php | 2 +- lib/AdminV1/ProductConnection.php | 2 +- lib/AdminV1/Subscription.php | 14 +- lib/AdminV1/SubscriptionItem.php | 4 +- lib/AdminV1/SubscriptionSeatInfo.php | 2 +- lib/AdminV1/Trigger.php | 2 +- lib/AdminV1/TriggerResult.php | 4 +- lib/AdminV1/User.php | 4 +- lib/AdminV1/UserInput.php | 2 +- lib/AdminV1/UserResult.php | 4 +- lib/ApiV1/EmptyResponse.php | 15 +- lib/Code.php | 38 ++ lib/CommonV1/Any.php | 2 - lib/ConnectionsV1/DeleteCustomUserRequest.php | 44 ++ lib/ConnectionsV1/GetCustomUserRequest.php | 44 ++ lib/ConnectionsV1/ListCustomUsersRequest.php | 57 +++ lib/EventsV1/Event.php | 10 +- lib/EventsV1/FlowsChanged.php | 2 +- lib/EventsV1/MembersChanged.php | 4 +- lib/EventsV1/OrganizationsChanged.php | 2 +- lib/EventsV1/SubscriptionsChanged.php | 2 +- lib/EventsV1/UsersChanged.php | 2 +- lib/Internal/Constants.php | 11 +- lib/Internal/CurlError.php | 5 +- .../{CaseInsensitiveArray.php => Headers.php} | 51 ++- lib/Internal/HttpTransport.php | 34 +- lib/Internal/JsonUnserializable.php | 3 + lib/Internal/Request.php | 9 +- lib/Internal/Response.php | 21 +- lib/Internal/TestTransport.php | 5 +- lib/Internal/Transport.php | 3 + lib/Internal/Util.php | 17 + lib/OperationsV1/Operation.php | 4 +- lib/Undefined.php | 5 +- lib/UserApi/Flows.php | 18 +- lib/UserApi/Invoices.php | 8 +- lib/UserApi/Organizations.php | 14 +- lib/UserHubError.php | 86 ++-- lib/UserV1/AccountSubscription.php | 4 +- lib/UserV1/AccountSubscriptionPlan.php | 2 +- lib/UserV1/AccountSubscriptionSeat.php | 2 +- lib/UserV1/BillingAccount.php | 2 +- lib/UserV1/CardPaymentMethod.php | 2 +- lib/UserV1/Flow.php | 10 +- lib/UserV1/Invoice.php | 8 +- lib/UserV1/InvoiceAccount.php | 2 +- lib/UserV1/InvoiceItem.php | 6 +- lib/UserV1/InvoicePreview.php | 4 +- lib/UserV1/InvoicePreviewItem.php | 6 +- lib/UserV1/Member.php | 6 +- lib/UserV1/Membership.php | 6 +- lib/UserV1/PaymentIntent.php | 2 +- lib/UserV1/PaymentMethod.php | 4 +- lib/UserV1/PaymentMethodIntent.php | 2 +- lib/UserV1/Plan.php | 2 +- lib/UserV1/PlanGroup.php | 4 +- lib/UserV1/PlanItem.php | 4 +- lib/UserV1/Price.php | 6 +- lib/UserV1/PriceFixedPrice.php | 2 +- lib/UserV1/Session.php | 4 +- lib/UserV1/Subscription.php | 8 +- lib/UserV1/SubscriptionItem.php | 4 +- lib/UserV1/SubscriptionSeatInfo.php | 2 +- lib/Webhook.php | 121 ++++++ lib/Webhook/BaseWebhook.php | 232 +++++++++++ lib/Webhook/DecodeHandler.php | 41 ++ lib/Webhook/Request.php | 99 +++++ lib/Webhook/Response.php | 27 ++ lib/Webhook/Util.php | 56 +++ tests/AdminApi/FlowsTest.php | 16 +- tests/AdminApi/InvoicesTest.php | 8 +- tests/AdminApi/OrganizationsTest.php | 52 +-- tests/AdminApi/SubscriptionsTest.php | 8 +- tests/AdminApi/UsersTest.php | 44 +- tests/BasicsTest.php | 252 ++++++++++++ tests/UserApi/FlowsTest.php | 20 +- tests/UserApi/InvoicesTest.php | 8 +- tests/UserApi/OrganizationsTest.php | 24 +- tests/UserApi/SessionTest.php | 4 +- tests/WebhookTest.php | 385 ++++++++++++++++++ 121 files changed, 2036 insertions(+), 438 deletions(-) create mode 100644 .editorconfig rename .php-cs-fixer.dist.php => .php-cs-fixer.php (77%) create mode 100644 Makefile rename {example => examples/api}/main.php (94%) create mode 100644 examples/webhook/main.php create mode 100644 lib/Code.php create mode 100644 lib/ConnectionsV1/DeleteCustomUserRequest.php create mode 100644 lib/ConnectionsV1/GetCustomUserRequest.php create mode 100644 lib/ConnectionsV1/ListCustomUsersRequest.php rename lib/Internal/{CaseInsensitiveArray.php => Headers.php} (73%) create mode 100644 lib/Webhook.php create mode 100644 lib/Webhook/BaseWebhook.php create mode 100644 lib/Webhook/DecodeHandler.php create mode 100644 lib/Webhook/Request.php create mode 100644 lib/Webhook/Response.php create mode 100644 lib/Webhook/Util.php create mode 100644 tests/BasicsTest.php create mode 100644 tests/WebhookTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5408a70 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.php] +indent_size = 4 + +[Makefile] +indent_size = 4 +indent_style = tab diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b03811f..642c67a 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -34,6 +34,8 @@ jobs: - name: Install dependencies run: composer install - - name: Run phpunit - run: | - ./vendor/bin/phpunit tests + - name: Lint + run: make lint + + - name: Test + run: make test diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.php similarity index 77% rename from .php-cs-fixer.dist.php rename to .php-cs-fixer.php index c6abae3..acfed95 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.php @@ -5,12 +5,15 @@ ; return (new PhpCsFixer\Config()) + ->setUsingCache(false) + ->setRiskyAllowed(true) ->setRules([ '@PSR12' => true, '@PhpCsFixer' => true, '@PhpCsFixer:risky' => true, '@PHP81Migration' => true, '@PHP80Migration:risky' => true, + 'php_unit_strict' => false, ]) ->setFinder($finder) ; diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2f71157 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: fmt +fmt: + ./vendor/bin/php-cs-fixer fix -v + # fully_qualified_strict_types not fully applied until + # run twice, hopefully we can remove this in the + # future + ./vendor/bin/php-cs-fixer fix -v + +.PHONY: lint +lint: + @./vendor/bin/php-cs-fixer check -v + +.PHONY: test +test: + @./vendor/bin/phpunit tests diff --git a/README.md b/README.md index 7722fc5..e00cd78 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,25 @@ Stability: alpha -## Usage +### Requirements + +* PHP 8.1 or later + +### Getting Started + +Install SDK + +```sh +composer require userhub/sdk +``` + +Example ```php users as $user) { echo $user->id.' '.$user->displayName.PHP_EOL; } ``` + +See the `examples` directory for more examples. diff --git a/composer.json b/composer.json index fa4ca04..3d53e2a 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "ext-json": "*" }, "require-dev": { + "donatj/mock-webserver": "^2.7", "friendsofphp/php-cs-fixer": "^3.49", "phpunit/phpunit": "^10" }, diff --git a/example/main.php b/examples/api/main.php similarity index 94% rename from example/main.php rename to examples/api/main.php index db4968e..729e546 100644 --- a/example/main.php +++ b/examples/api/main.php @@ -2,7 +2,7 @@ declare(strict_types=1); -require __DIR__.'/../vendor/autoload.php'; +require __DIR__.'/../../vendor/autoload.php'; use UserHub\AdminApi; use UserHub\UserApi; @@ -16,7 +16,7 @@ } $userKey = getenv('USER_KEY'); -if (empty($adminKey)) { +if (empty($userKey)) { echo 'USER_KEY required'.PHP_EOL; exit(1); diff --git a/examples/webhook/main.php b/examples/webhook/main.php new file mode 100644 index 0000000..59bf37d --- /dev/null +++ b/examples/webhook/main.php @@ -0,0 +1,44 @@ +onEvent(static function (Event $event): void { + error_log('Event: '.$event->type); + + switch ($event->type) { + case 'organizations.changed': + $organization = $event->organizationsChanged->organization; + error_log(" - Organization: {$organization->id} {$organization->displayName}"); + + break; + + case 'users.changed': + $user = $event->usersChanged->user; + error_log(" - User: {$user->id} {$user->displayName}"); + + break; + } +}); + +$wh->handleFromGlobals(); diff --git a/lib/AdminApi/Flows.php b/lib/AdminApi/Flows.php index 3892791..f9e7e80 100644 --- a/lib/AdminApi/Flows.php +++ b/lib/AdminApi/Flows.php @@ -42,25 +42,25 @@ public function list( $req = new Request('admin.flows.list', 'GET', '/admin/v1/flows'); $req->setIdempotent(true); - if (isset($organizationId)) { + if (!empty($organizationId)) { $req->setQuery('organizationId', $organizationId); } - if (isset($userId)) { + if (!empty($userId)) { $req->setQuery('userId', $userId); } - if (isset($type)) { + if (!empty($type)) { $req->setQuery('type', $type); } - if (isset($pageSize)) { + if (!empty($pageSize)) { $req->setQuery('pageSize', $pageSize); } - if (isset($pageToken)) { + if (!empty($pageToken)) { $req->setQuery('pageToken', $pageToken); } - if (isset($orderBy)) { + if (!empty($orderBy)) { $req->setQuery('orderBy', $orderBy); } - if (isset($view)) { + if (!empty($view)) { $req->setQuery('view', $view); } @@ -88,25 +88,25 @@ public function createJoinOrganization( $req = new Request('admin.flows.createJoinOrganization', 'POST', '/admin/v1/flows:createJoinOrganization'); $body = []; - if (isset($organizationId)) { + if (!empty($organizationId)) { $body['organizationId'] = $organizationId; } - if (isset($userId)) { + if (!empty($userId)) { $body['userId'] = $userId; } - if (isset($email)) { + if (!empty($email)) { $body['email'] = $email; } - if (isset($displayName)) { + if (!empty($displayName)) { $body['displayName'] = $displayName; } - if (isset($creatorUserId)) { + if (!empty($creatorUserId)) { $body['creatorUserId'] = $creatorUserId; } - if (isset($expireTime)) { + if (!empty($expireTime)) { $body['expireTime'] = Util::encodeDateTime($expireTime); } - if (isset($ttl)) { + if (!empty($ttl)) { $body['ttl'] = $ttl; } diff --git a/lib/AdminApi/Invoices.php b/lib/AdminApi/Invoices.php index 4b7e984..6a2235c 100644 --- a/lib/AdminApi/Invoices.php +++ b/lib/AdminApi/Invoices.php @@ -39,19 +39,19 @@ public function list( $req = new Request('admin.invoices.list', 'GET', '/admin/v1/invoices'); $req->setIdempotent(true); - if (isset($organizationId)) { + if (!empty($organizationId)) { $req->setQuery('organizationId', $organizationId); } - if (isset($userId)) { + if (!empty($userId)) { $req->setQuery('userId', $userId); } - if (isset($pageSize)) { + if (!empty($pageSize)) { $req->setQuery('pageSize', $pageSize); } - if (isset($pageToken)) { + if (!empty($pageToken)) { $req->setQuery('pageToken', $pageToken); } - if (isset($orderBy)) { + if (!empty($orderBy)) { $req->setQuery('orderBy', $orderBy); } @@ -73,10 +73,10 @@ public function get( $req = new Request('admin.invoices.get', 'GET', '/admin/v1/invoices/'.rawurlencode($invoiceId)); $req->setIdempotent(true); - if (isset($organizationId)) { + if (!empty($organizationId)) { $req->setQuery('organizationId', $organizationId); } - if (isset($userId)) { + if (!empty($userId)) { $req->setQuery('userId', $userId); } diff --git a/lib/AdminApi/Organizations.php b/lib/AdminApi/Organizations.php index c4cbc46..4bd7a07 100644 --- a/lib/AdminApi/Organizations.php +++ b/lib/AdminApi/Organizations.php @@ -45,19 +45,19 @@ public function list( $req = new Request('admin.organizations.list', 'GET', '/admin/v1/organizations'); $req->setIdempotent(true); - if (isset($pageSize)) { + if (!empty($pageSize)) { $req->setQuery('pageSize', $pageSize); } - if (isset($pageToken)) { + if (!empty($pageToken)) { $req->setQuery('pageToken', $pageToken); } - if (isset($orderBy)) { + if (!empty($orderBy)) { $req->setQuery('orderBy', $orderBy); } - if (isset($showDeleted)) { + if (!empty($showDeleted)) { $req->setQuery('showDeleted', $showDeleted); } - if (isset($view)) { + if (!empty($view)) { $req->setQuery('view', $view); } @@ -90,46 +90,46 @@ public function create( $req = new Request('admin.organizations.create', 'POST', '/admin/v1/organizations'); $body = []; - if (isset($uniqueId)) { + if (!empty($uniqueId)) { $body['uniqueId'] = $uniqueId; } - if (isset($displayName)) { + if (!empty($displayName)) { $body['displayName'] = $displayName; } - if (isset($email)) { + if (!empty($email)) { $body['email'] = $email; } - if (isset($emailVerified)) { + if (!empty($emailVerified)) { $body['emailVerified'] = $emailVerified; } - if (isset($phoneNumber)) { + if (!empty($phoneNumber)) { $body['phoneNumber'] = $phoneNumber; } - if (isset($phoneNumberVerified)) { + if (!empty($phoneNumberVerified)) { $body['phoneNumberVerified'] = $phoneNumberVerified; } - if (isset($imageUrl)) { + if (!empty($imageUrl)) { $body['imageUrl'] = $imageUrl; } - if (isset($currencyCode)) { + if (!empty($currencyCode)) { $body['currencyCode'] = $currencyCode; } - if (isset($languageCode)) { + if (!empty($languageCode)) { $body['languageCode'] = $languageCode; } - if (isset($regionCode)) { + if (!empty($regionCode)) { $body['regionCode'] = $regionCode; } - if (isset($timeZone)) { + if (!empty($timeZone)) { $body['timeZone'] = $timeZone; } - if (isset($address)) { + if (!empty($address)) { $body['address'] = $address; } - if (isset($signupTime)) { + if (!empty($signupTime)) { $body['signupTime'] = Util::encodeDateTime($signupTime); } - if (isset($disabled)) { + if (!empty($disabled)) { $body['disabled'] = $disabled; } @@ -184,7 +184,7 @@ public function update( $body = []; - if (isset($allowMissing)) { + if (!empty($allowMissing)) { $req->setQuery('allowMissing', $allowMissing); } if (!Undefined::is($uniqueId)) { @@ -286,10 +286,10 @@ public function connect( $req = new Request('admin.organizations.connect', 'POST', '/admin/v1/organizations/'.rawurlencode($organizationId).':connect'); $body = []; - if (isset($connectionId)) { + if (!empty($connectionId)) { $body['connectionId'] = $connectionId; } - if (isset($externalId)) { + if (!empty($externalId)) { $body['externalId'] = $externalId; } @@ -322,10 +322,10 @@ public function disconnect( $req = new Request('admin.organizations.disconnect', 'POST', '/admin/v1/organizations/'.rawurlencode($organizationId).':disconnect'); $body = []; - if (isset($connectionId)) { + if (!empty($connectionId)) { $body['connectionId'] = $connectionId; } - if (isset($deleteExternalAccount)) { + if (!empty($deleteExternalAccount)) { $body['deleteExternalAccount'] = $deleteExternalAccount; } @@ -350,13 +350,13 @@ public function listMembers( $req = new Request('admin.organizations.listMembers', 'GET', '/admin/v1/organizations/'.rawurlencode($organizationId).'/members'); $req->setIdempotent(true); - if (isset($pageSize)) { + if (!empty($pageSize)) { $req->setQuery('pageSize', $pageSize); } - if (isset($pageToken)) { + if (!empty($pageToken)) { $req->setQuery('pageToken', $pageToken); } - if (isset($orderBy)) { + if (!empty($orderBy)) { $req->setQuery('orderBy', $orderBy); } @@ -378,10 +378,10 @@ public function addMember( $req = new Request('admin.organizations.addMember', 'POST', '/admin/v1/organizations/'.rawurlencode($organizationId).'/members'); $body = []; - if (isset($userId)) { + if (!empty($userId)) { $body['userId'] = $userId; } - if (isset($roleId)) { + if (!empty($roleId)) { $body['roleId'] = $roleId; } @@ -425,7 +425,7 @@ public function updateMember( $body = []; - if (isset($allowMissing)) { + if (!empty($allowMissing)) { $req->setQuery('allowMissing', $allowMissing); } if (!Undefined::is($roleId)) { diff --git a/lib/AdminApi/Subscriptions.php b/lib/AdminApi/Subscriptions.php index 9bc56ad..6f9c233 100644 --- a/lib/AdminApi/Subscriptions.php +++ b/lib/AdminApi/Subscriptions.php @@ -40,22 +40,22 @@ public function list( $req = new Request('admin.subscriptions.list', 'GET', '/admin/v1/subscriptions'); $req->setIdempotent(true); - if (isset($organizationId)) { + if (!empty($organizationId)) { $req->setQuery('organizationId', $organizationId); } - if (isset($userId)) { + if (!empty($userId)) { $req->setQuery('userId', $userId); } - if (isset($pageSize)) { + if (!empty($pageSize)) { $req->setQuery('pageSize', $pageSize); } - if (isset($pageToken)) { + if (!empty($pageToken)) { $req->setQuery('pageToken', $pageToken); } - if (isset($orderBy)) { + if (!empty($orderBy)) { $req->setQuery('orderBy', $orderBy); } - if (isset($view)) { + if (!empty($view)) { $req->setQuery('view', $view); } @@ -77,10 +77,10 @@ public function get( $req = new Request('admin.subscriptions.get', 'GET', '/admin/v1/subscriptions/'.rawurlencode($subscriptionId)); $req->setIdempotent(true); - if (isset($organizationId)) { + if (!empty($organizationId)) { $req->setQuery('organizationId', $organizationId); } - if (isset($userId)) { + if (!empty($userId)) { $req->setQuery('userId', $userId); } diff --git a/lib/AdminApi/Users.php b/lib/AdminApi/Users.php index 3d95b3c..c1554fb 100644 --- a/lib/AdminApi/Users.php +++ b/lib/AdminApi/Users.php @@ -44,19 +44,19 @@ public function list( $req = new Request('admin.users.list', 'GET', '/admin/v1/users'); $req->setIdempotent(true); - if (isset($pageSize)) { + if (!empty($pageSize)) { $req->setQuery('pageSize', $pageSize); } - if (isset($pageToken)) { + if (!empty($pageToken)) { $req->setQuery('pageToken', $pageToken); } - if (isset($orderBy)) { + if (!empty($orderBy)) { $req->setQuery('orderBy', $orderBy); } - if (isset($showDeleted)) { + if (!empty($showDeleted)) { $req->setQuery('showDeleted', $showDeleted); } - if (isset($view)) { + if (!empty($view)) { $req->setQuery('view', $view); } @@ -89,46 +89,46 @@ public function create( $req = new Request('admin.users.create', 'POST', '/admin/v1/users'); $body = []; - if (isset($uniqueId)) { + if (!empty($uniqueId)) { $body['uniqueId'] = $uniqueId; } - if (isset($displayName)) { + if (!empty($displayName)) { $body['displayName'] = $displayName; } - if (isset($email)) { + if (!empty($email)) { $body['email'] = $email; } - if (isset($emailVerified)) { + if (!empty($emailVerified)) { $body['emailVerified'] = $emailVerified; } - if (isset($phoneNumber)) { + if (!empty($phoneNumber)) { $body['phoneNumber'] = $phoneNumber; } - if (isset($phoneNumberVerified)) { + if (!empty($phoneNumberVerified)) { $body['phoneNumberVerified'] = $phoneNumberVerified; } - if (isset($imageUrl)) { + if (!empty($imageUrl)) { $body['imageUrl'] = $imageUrl; } - if (isset($currencyCode)) { + if (!empty($currencyCode)) { $body['currencyCode'] = $currencyCode; } - if (isset($languageCode)) { + if (!empty($languageCode)) { $body['languageCode'] = $languageCode; } - if (isset($regionCode)) { + if (!empty($regionCode)) { $body['regionCode'] = $regionCode; } - if (isset($timeZone)) { + if (!empty($timeZone)) { $body['timeZone'] = $timeZone; } - if (isset($address)) { + if (!empty($address)) { $body['address'] = $address; } - if (isset($signupTime)) { + if (!empty($signupTime)) { $body['signupTime'] = Util::encodeDateTime($signupTime); } - if (isset($disabled)) { + if (!empty($disabled)) { $body['disabled'] = $disabled; } @@ -183,7 +183,7 @@ public function update( $body = []; - if (isset($allowMissing)) { + if (!empty($allowMissing)) { $req->setQuery('allowMissing', $allowMissing); } if (!Undefined::is($uniqueId)) { @@ -281,10 +281,10 @@ public function connect( $req = new Request('admin.users.connect', 'POST', '/admin/v1/users/'.rawurlencode($userId).':connect'); $body = []; - if (isset($connectionId)) { + if (!empty($connectionId)) { $body['connectionId'] = $connectionId; } - if (isset($externalId)) { + if (!empty($externalId)) { $body['externalId'] = $externalId; } @@ -317,10 +317,10 @@ public function disconnect( $req = new Request('admin.users.disconnect', 'POST', '/admin/v1/users/'.rawurlencode($userId).':disconnect'); $body = []; - if (isset($connectionId)) { + if (!empty($connectionId)) { $body['connectionId'] = $connectionId; } - if (isset($deleteExternalAccount)) { + if (!empty($deleteExternalAccount)) { $body['deleteExternalAccount'] = $deleteExternalAccount; } @@ -390,13 +390,13 @@ public function createPortalSession( $body = []; - if (isset($portalUrl)) { + if (!empty($portalUrl)) { $body['portalUrl'] = $portalUrl; } - if (isset($returnUrl)) { + if (!empty($returnUrl)) { $body['returnUrl'] = $returnUrl; } - if (isset($successUrl)) { + if (!empty($successUrl)) { $body['successUrl'] = $successUrl; } diff --git a/lib/AdminV1/AccountConnection.php b/lib/AdminV1/AccountConnection.php index a9726b5..b2cd936 100644 --- a/lib/AdminV1/AccountConnection.php +++ b/lib/AdminV1/AccountConnection.php @@ -17,7 +17,7 @@ class AccountConnection implements \JsonSerializable, JsonUnserializable /** * The tenant connection. */ - public null|\UserHub\AdminV1\Connection $connection; + public null|Connection $connection; /** * The external identifier of the connected account. diff --git a/lib/AdminV1/AccountSubscription.php b/lib/AdminV1/AccountSubscription.php index 6d8845d..0a2a852 100644 --- a/lib/AdminV1/AccountSubscription.php +++ b/lib/AdminV1/AccountSubscription.php @@ -32,7 +32,7 @@ class AccountSubscription implements \JsonSerializable, JsonUnserializable /** * The plan. */ - public null|\UserHub\AdminV1\AccountSubscriptionPlan $plan; + public null|AccountSubscriptionPlan $plan; public function __construct( null|string $id = null, diff --git a/lib/AdminV1/AccountSubscriptionPlan.php b/lib/AdminV1/AccountSubscriptionPlan.php index 0edea55..d5ce212 100644 --- a/lib/AdminV1/AccountSubscriptionPlan.php +++ b/lib/AdminV1/AccountSubscriptionPlan.php @@ -26,7 +26,7 @@ class AccountSubscriptionPlan implements \JsonSerializable, JsonUnserializable /** * The plan product. */ - public null|\UserHub\AdminV1\AccountSubscriptionProduct $product; + public null|AccountSubscriptionProduct $product; public function __construct( null|string $id = null, diff --git a/lib/AdminV1/AccountSubscriptionSeat.php b/lib/AdminV1/AccountSubscriptionSeat.php index a16a1f5..86363b3 100644 --- a/lib/AdminV1/AccountSubscriptionSeat.php +++ b/lib/AdminV1/AccountSubscriptionSeat.php @@ -16,7 +16,7 @@ class AccountSubscriptionSeat implements \JsonSerializable, JsonUnserializable /** * The seat product. */ - public null|\UserHub\AdminV1\AccountSubscriptionProduct $product; + public null|AccountSubscriptionProduct $product; public function __construct( null|AccountSubscriptionProduct $product = null, diff --git a/lib/AdminV1/CardPaymentMethod.php b/lib/AdminV1/CardPaymentMethod.php index bb900e0..31fd62c 100644 --- a/lib/AdminV1/CardPaymentMethod.php +++ b/lib/AdminV1/CardPaymentMethod.php @@ -21,7 +21,7 @@ class CardPaymentMethod implements \JsonSerializable, JsonUnserializable /** * The expiration date of the card. */ - public null|\UserHub\AdminV1\CardPaymentMethodExpiration $expiration; + public null|CardPaymentMethodExpiration $expiration; /** * The last for digits of the card. diff --git a/lib/AdminV1/Connection.php b/lib/AdminV1/Connection.php index 8470994..f5af7a4 100644 --- a/lib/AdminV1/Connection.php +++ b/lib/AdminV1/Connection.php @@ -55,7 +55,7 @@ class Connection implements \JsonSerializable, JsonUnserializable /** * The delegated connection. */ - public null|\UserHub\AdminV1\ConnectionDelegate $delegate; + public null|ConnectionDelegate $delegate; /** * The connection providers. @@ -77,32 +77,32 @@ class Connection implements \JsonSerializable, JsonUnserializable /** * The Auth0 connection data. */ - public null|\UserHub\AdminV1\Auth0Connection $auth0; + public null|Auth0Connection $auth0; /** * The builtin email configuration data. */ - public null|\UserHub\AdminV1\BuiltinEmailConnection $builtinEmail; + public null|BuiltinEmailConnection $builtinEmail; /** * The Google Cloud Identity Platform (Firebase Auth) connection. */ - public null|\UserHub\AdminV1\GoogleCloudIdentityPlatformConnection $googleCloudIdentityPlatform; + public null|GoogleCloudIdentityPlatformConnection $googleCloudIdentityPlatform; /** * The Postmark configuration data. */ - public null|\UserHub\AdminV1\PostmarkConnection $postmark; + public null|PostmarkConnection $postmark; /** * The Stripe billing configuration data. */ - public null|\UserHub\AdminV1\StripeConnection $stripe; + public null|StripeConnection $stripe; /** * The webhooks configuration data. */ - public null|\UserHub\AdminV1\WebhookConnection $webhook; + public null|WebhookConnection $webhook; public function __construct( null|string $id = null, diff --git a/lib/AdminV1/Event.php b/lib/AdminV1/Event.php index 5ba8c76..c7d92d9 100644 --- a/lib/AdminV1/Event.php +++ b/lib/AdminV1/Event.php @@ -33,37 +33,37 @@ class Event implements \JsonSerializable, JsonUnserializable /** * The entity associated with the event. */ - public null|\UserHub\AdminV1\EventEntity $entity; + public null|EventEntity $entity; /** * The connection associated with the event. */ - public null|\UserHub\AdminV1\EventConnection $connection; + public null|EventConnection $connection; /** * The organization associated with the event. */ - public null|\UserHub\AdminV1\EventOrganization $organization; + public null|EventOrganization $organization; /** * The user associated with the event. */ - public null|\UserHub\AdminV1\EventUser $user; + public null|EventUser $user; /** * The API key associated with the event. */ - public null|\UserHub\AdminV1\EventApiKey $apiKey; + public null|EventApiKey $apiKey; /** * The actor associated with the event. */ - public null|\UserHub\AdminV1\EventActor $actor; + public null|EventActor $actor; /** * The request associated with the event. */ - public null|\UserHub\AdminV1\EventRequest $request; + public null|EventRequest $request; public function __construct( null|string $id = null, diff --git a/lib/AdminV1/Flow.php b/lib/AdminV1/Flow.php index c233565..196e132 100644 --- a/lib/AdminV1/Flow.php +++ b/lib/AdminV1/Flow.php @@ -37,19 +37,19 @@ class Flow implements \JsonSerializable, JsonUnserializable /** * The target organization for the flow. */ - public null|\UserHub\AdminV1\Organization $organization; + public null|Organization $organization; /** * The target user for the flow. */ - public null|\UserHub\AdminV1\User $user; + public null|User $user; /** * The user who created the flow. * * This will not be set if the invitation was created by an admin. */ - public null|\UserHub\AdminV1\User $creator; + public null|User $creator; /** * The start time of the flow. @@ -86,12 +86,12 @@ class Flow implements \JsonSerializable, JsonUnserializable /** * The join organization flow type specific data. */ - public null|\UserHub\AdminV1\JoinOrganizationFlow $joinOrganization; + public null|JoinOrganizationFlow $joinOrganization; /** * The signup flow type specific data. */ - public null|\UserHub\AdminV1\SignupFlow $signup; + public null|SignupFlow $signup; public function __construct( null|string $id = null, diff --git a/lib/AdminV1/Invoice.php b/lib/AdminV1/Invoice.php index 2b813fe..7b0fc10 100644 --- a/lib/AdminV1/Invoice.php +++ b/lib/AdminV1/Invoice.php @@ -38,7 +38,7 @@ class Invoice implements \JsonSerializable, JsonUnserializable /** * The billing connection. */ - public null|\UserHub\AdminV1\Connection $connection; + public null|Connection $connection; /** * The external identifier of the invoice. @@ -63,7 +63,7 @@ class Invoice implements \JsonSerializable, JsonUnserializable /** * The bill to contact information. */ - public null|\UserHub\AdminV1\InvoiceAccount $account; + public null|InvoiceAccount $account; /** * The time the invoice was finalized. @@ -73,7 +73,7 @@ class Invoice implements \JsonSerializable, JsonUnserializable /** * The billing period for the invoice. */ - public null|\UserHub\CommonV1\Period $period; + public null|Period $period; /** * The subtotal amount for the invoice. @@ -93,7 +93,7 @@ class Invoice implements \JsonSerializable, JsonUnserializable * The starting and ending account balance as * of the time the invoice was finalized. */ - public null|\UserHub\AdminV1\InvoiceBalance $balance; + public null|InvoiceBalance $balance; /** * The tax amount for the invoice. @@ -137,7 +137,7 @@ class Invoice implements \JsonSerializable, JsonUnserializable /** * The payment intent for the invoice. */ - public null|\UserHub\AdminV1\PaymentIntent $paymentIntent; + public null|PaymentIntent $paymentIntent; /** * The line items for the invoice. diff --git a/lib/AdminV1/InvoiceAccount.php b/lib/AdminV1/InvoiceAccount.php index 15e0c61..f8d5ae8 100644 --- a/lib/AdminV1/InvoiceAccount.php +++ b/lib/AdminV1/InvoiceAccount.php @@ -32,7 +32,7 @@ class InvoiceAccount implements \JsonSerializable, JsonUnserializable /** * The billing address. */ - public null|\UserHub\CommonV1\Address $address; + public null|Address $address; public function __construct( null|string $fullName = null, diff --git a/lib/AdminV1/InvoiceItem.php b/lib/AdminV1/InvoiceItem.php index 05b17f8..2e65de6 100644 --- a/lib/AdminV1/InvoiceItem.php +++ b/lib/AdminV1/InvoiceItem.php @@ -22,12 +22,12 @@ class InvoiceItem implements \JsonSerializable, JsonUnserializable /** * The details of the associated product. */ - public null|\UserHub\AdminV1\Product $product; + public null|Product $product; /** * The details of the associated price. */ - public null|\UserHub\AdminV1\Price $price; + public null|Price $price; /** * The quantity of the item product/price. @@ -63,7 +63,7 @@ class InvoiceItem implements \JsonSerializable, JsonUnserializable /** * The billing period for the item. */ - public null|\UserHub\CommonV1\Period $period; + public null|Period $period; public function __construct( null|string $id = null, diff --git a/lib/AdminV1/InvoicePreview.php b/lib/AdminV1/InvoicePreview.php index 454e8e7..49de37a 100644 --- a/lib/AdminV1/InvoicePreview.php +++ b/lib/AdminV1/InvoicePreview.php @@ -22,7 +22,7 @@ class InvoicePreview implements \JsonSerializable, JsonUnserializable /** * The bill to contact information. */ - public null|\UserHub\AdminV1\InvoiceAccount $account; + public null|InvoiceAccount $account; /** * The time the upcoming invoice will be finalized. @@ -53,7 +53,7 @@ class InvoicePreview implements \JsonSerializable, JsonUnserializable * The starting and ending account balance as * of the time the preview. */ - public null|\UserHub\AdminV1\InvoiceBalance $balance; + public null|InvoiceBalance $balance; /** * The tax amount for the preview. diff --git a/lib/AdminV1/InvoicePreviewItem.php b/lib/AdminV1/InvoicePreviewItem.php index a95c46c..61b39d0 100644 --- a/lib/AdminV1/InvoicePreviewItem.php +++ b/lib/AdminV1/InvoicePreviewItem.php @@ -17,12 +17,12 @@ class InvoicePreviewItem implements \JsonSerializable, JsonUnserializable /** * The details of the associated product. */ - public null|\UserHub\AdminV1\Product $product; + public null|Product $product; /** * The details of the associated price. */ - public null|\UserHub\AdminV1\Price $price; + public null|Price $price; /** * The quantity of the item product/price. @@ -53,7 +53,7 @@ class InvoicePreviewItem implements \JsonSerializable, JsonUnserializable /** * The billing period for the item. */ - public null|\UserHub\CommonV1\Period $period; + public null|Period $period; public function __construct( null|Product $product = null, diff --git a/lib/AdminV1/Member.php b/lib/AdminV1/Member.php index ff60f27..ce7ac70 100644 --- a/lib/AdminV1/Member.php +++ b/lib/AdminV1/Member.php @@ -22,12 +22,12 @@ class Member implements \JsonSerializable, JsonUnserializable /** * The user. */ - public null|\UserHub\AdminV1\User $user; + public null|User $user; /** * The user's role within the organization. */ - public null|\UserHub\AdminV1\Role $role; + public null|Role $role; /** * The seat associated with the member. @@ -36,7 +36,7 @@ class Member implements \JsonSerializable, JsonUnserializable * subscription for the organization or the user * has not been assigned a seat. */ - public null|\UserHub\AdminV1\AccountSubscriptionSeat $seat; + public null|AccountSubscriptionSeat $seat; /** * The creation time of the membership. diff --git a/lib/AdminV1/Membership.php b/lib/AdminV1/Membership.php index bf18d61..806c0cf 100644 --- a/lib/AdminV1/Membership.php +++ b/lib/AdminV1/Membership.php @@ -20,12 +20,12 @@ class Membership implements \JsonSerializable, JsonUnserializable /** * The organization. */ - public null|\UserHub\AdminV1\Organization $organization; + public null|Organization $organization; /** * The user's role within the organization. */ - public null|\UserHub\AdminV1\Role $role; + public null|Role $role; /** * The seat associated with the membership. @@ -34,7 +34,7 @@ class Membership implements \JsonSerializable, JsonUnserializable * subscription for the organization or the user * has not been assigned a seat. */ - public null|\UserHub\AdminV1\AccountSubscriptionSeat $seat; + public null|AccountSubscriptionSeat $seat; /** * The creation time of the membership. diff --git a/lib/AdminV1/Organization.php b/lib/AdminV1/Organization.php index 7443b8e..602a051 100644 --- a/lib/AdminV1/Organization.php +++ b/lib/AdminV1/Organization.php @@ -88,7 +88,7 @@ class Organization implements \JsonSerializable, JsonUnserializable /** * The address for the organization. */ - public null|\UserHub\CommonV1\Address $address; + public null|Address $address; /** * The connected accounts. @@ -100,7 +100,7 @@ class Organization implements \JsonSerializable, JsonUnserializable /** * The organization's default active subscription. */ - public null|\UserHub\AdminV1\AccountSubscription $subscription; + public null|AccountSubscription $subscription; /** * The sign-up time for the organization. diff --git a/lib/AdminV1/OrganizationInput.php b/lib/AdminV1/OrganizationInput.php index 70b4b71..7b2cb15 100644 --- a/lib/AdminV1/OrganizationInput.php +++ b/lib/AdminV1/OrganizationInput.php @@ -90,7 +90,7 @@ class OrganizationInput implements \JsonSerializable, JsonUnserializable /** * The billing address for the organization. */ - public null|\UserHub\CommonV1\Address $address; + public null|Address $address; /** * The sign-up time for the organization. diff --git a/lib/AdminV1/OrganizationResult.php b/lib/AdminV1/OrganizationResult.php index 5cd4411..4f38a61 100644 --- a/lib/AdminV1/OrganizationResult.php +++ b/lib/AdminV1/OrganizationResult.php @@ -17,12 +17,12 @@ class OrganizationResult implements \JsonSerializable, JsonUnserializable /** * The organization. */ - public null|\UserHub\AdminV1\Organization $organization; + public null|Organization $organization; /** * The organization error. */ - public null|\UserHub\ApiV1\Status $error; + public null|Status $error; public function __construct( null|Organization $organization = null, diff --git a/lib/AdminV1/PaymentIntent.php b/lib/AdminV1/PaymentIntent.php index 0c7800e..97368e2 100644 --- a/lib/AdminV1/PaymentIntent.php +++ b/lib/AdminV1/PaymentIntent.php @@ -16,7 +16,7 @@ class PaymentIntent implements \JsonSerializable, JsonUnserializable /** * A Stripe payment intent. */ - public null|\UserHub\AdminV1\StripePaymentIntent $stripe; + public null|StripePaymentIntent $stripe; public function __construct( null|StripePaymentIntent $stripe = null, diff --git a/lib/AdminV1/PaymentMethod.php b/lib/AdminV1/PaymentMethod.php index 28f3870..f4f9f60 100644 --- a/lib/AdminV1/PaymentMethod.php +++ b/lib/AdminV1/PaymentMethod.php @@ -56,7 +56,7 @@ class PaymentMethod implements \JsonSerializable, JsonUnserializable /** * The address for the payment method. */ - public null|\UserHub\CommonV1\Address $address; + public null|Address $address; /** * Whether the payment method is the default for the connected account. @@ -81,7 +81,7 @@ class PaymentMethod implements \JsonSerializable, JsonUnserializable /** * Card payment method (e.g. Visa credit card). */ - public null|\UserHub\AdminV1\CardPaymentMethod $card; + public null|CardPaymentMethod $card; public function __construct( null|string $id = null, diff --git a/lib/AdminV1/Plan.php b/lib/AdminV1/Plan.php index 12cb220..9f4b97b 100644 --- a/lib/AdminV1/Plan.php +++ b/lib/AdminV1/Plan.php @@ -38,7 +38,7 @@ class Plan implements \JsonSerializable, JsonUnserializable /** * The billing interval for the plan. */ - public null|\UserHub\CommonV1\Interval $billingInterval; + public null|Interval $billingInterval; /** * The tags associated with the revision. diff --git a/lib/AdminV1/PlanGroup.php b/lib/AdminV1/PlanGroup.php index e604b60..42f8893 100644 --- a/lib/AdminV1/PlanGroup.php +++ b/lib/AdminV1/PlanGroup.php @@ -57,7 +57,7 @@ class PlanGroup implements \JsonSerializable, JsonUnserializable /** * The trial settings. */ - public null|\UserHub\AdminV1\PlanGroupTrial $trial; + public null|PlanGroupTrial $trial; /** * The visibility of the plan group. @@ -72,7 +72,7 @@ class PlanGroup implements \JsonSerializable, JsonUnserializable /** * The current revision for the plan group. */ - public null|\UserHub\AdminV1\PlanGroupRevision $revision; + public null|PlanGroupRevision $revision; /** * The creation time of the plan group. diff --git a/lib/AdminV1/PlanGroupChangePath.php b/lib/AdminV1/PlanGroupChangePath.php index f008b0e..32825f0 100644 --- a/lib/AdminV1/PlanGroupChangePath.php +++ b/lib/AdminV1/PlanGroupChangePath.php @@ -17,7 +17,7 @@ class PlanGroupChangePath implements \JsonSerializable, JsonUnserializable /** * The target plan group for this change path. */ - public null|\UserHub\AdminV1\PlanGroup $target; + public null|PlanGroup $target; /** * Whether the change is considered an upgrade or diff --git a/lib/AdminV1/PlanGroupRevisionItem.php b/lib/AdminV1/PlanGroupRevisionItem.php index 5b1dd07..e3349e3 100644 --- a/lib/AdminV1/PlanGroupRevisionItem.php +++ b/lib/AdminV1/PlanGroupRevisionItem.php @@ -16,7 +16,7 @@ class PlanGroupRevisionItem implements \JsonSerializable, JsonUnserializable /** * The product associated with the item. */ - public null|\UserHub\AdminV1\Product $product; + public null|Product $product; /** * The plan item type. diff --git a/lib/AdminV1/PlanGroupRevisionPlan.php b/lib/AdminV1/PlanGroupRevisionPlan.php index de8796c..fa90d26 100644 --- a/lib/AdminV1/PlanGroupRevisionPlan.php +++ b/lib/AdminV1/PlanGroupRevisionPlan.php @@ -24,12 +24,12 @@ class PlanGroupRevisionPlan implements \JsonSerializable, JsonUnserializable /** * The details of the associated connection. */ - public null|\UserHub\AdminV1\Connection $connection; + public null|Connection $connection; /** * The billing interval for the plan. */ - public null|\UserHub\CommonV1\Interval $interval; + public null|Interval $interval; /** * The customer facing human-readable display name for the plan. diff --git a/lib/AdminV1/PlanItem.php b/lib/AdminV1/PlanItem.php index bbfb7e2..02c731f 100644 --- a/lib/AdminV1/PlanItem.php +++ b/lib/AdminV1/PlanItem.php @@ -16,12 +16,12 @@ class PlanItem implements \JsonSerializable, JsonUnserializable /** * The product associated with the item. */ - public null|\UserHub\AdminV1\Product $product; + public null|Product $product; /** * The price associated with them item. */ - public null|\UserHub\AdminV1\Price $price; + public null|Price $price; /** * The plan item type. diff --git a/lib/AdminV1/PostmarkConnection.php b/lib/AdminV1/PostmarkConnection.php index 59dd1d9..03047fe 100644 --- a/lib/AdminV1/PostmarkConnection.php +++ b/lib/AdminV1/PostmarkConnection.php @@ -32,12 +32,12 @@ class PostmarkConnection implements \JsonSerializable, JsonUnserializable * The Postmark account must be allowed to send from this email * address. */ - public null|\UserHub\CommonV1\Email $from; + public null|Email $from; /** * The reply to email address. */ - public null|\UserHub\CommonV1\Email $replyTo; + public null|Email $replyTo; /** * The allowed email list. diff --git a/lib/AdminV1/Price.php b/lib/AdminV1/Price.php index 39de874..52a21ef 100644 --- a/lib/AdminV1/Price.php +++ b/lib/AdminV1/Price.php @@ -23,7 +23,7 @@ class Price implements \JsonSerializable, JsonUnserializable /** * The connection. */ - public null|\UserHub\AdminV1\Connection $connection; + public null|Connection $connection; /** * The external identifier of the connected price. @@ -53,7 +53,7 @@ class Price implements \JsonSerializable, JsonUnserializable /** * The billing interval for the price. */ - public null|\UserHub\CommonV1\Interval $interval; + public null|Interval $interval; /** * The admin-facing display name of the price. @@ -63,7 +63,7 @@ class Price implements \JsonSerializable, JsonUnserializable /** * The product associated with the price. */ - public null|\UserHub\AdminV1\Product $product; + public null|Product $product; /** * The archived status of the price. @@ -95,12 +95,12 @@ class Price implements \JsonSerializable, JsonUnserializable /** * The price is fixed per quantity. */ - public null|\UserHub\AdminV1\PriceFixedPrice $fixed; + public null|PriceFixedPrice $fixed; /** * The price is dependent on the quantity. */ - public null|\UserHub\AdminV1\PriceTieredPrice $tiered; + public null|PriceTieredPrice $tiered; public function __construct( null|string $id = null, diff --git a/lib/AdminV1/PriceFixedPrice.php b/lib/AdminV1/PriceFixedPrice.php index c8a7bce..8dec3a0 100644 --- a/lib/AdminV1/PriceFixedPrice.php +++ b/lib/AdminV1/PriceFixedPrice.php @@ -22,7 +22,7 @@ class PriceFixedPrice implements \JsonSerializable, JsonUnserializable /** * Whether to transform the quantity before multiplying amount. */ - public null|\UserHub\AdminV1\PriceTransformQuantity $transformQuantity; + public null|PriceTransformQuantity $transformQuantity; public function __construct( null|string $amount = null, diff --git a/lib/AdminV1/ProductConnection.php b/lib/AdminV1/ProductConnection.php index 054a9db..6760968 100644 --- a/lib/AdminV1/ProductConnection.php +++ b/lib/AdminV1/ProductConnection.php @@ -17,7 +17,7 @@ class ProductConnection implements \JsonSerializable, JsonUnserializable /** * The basic view of the connection. */ - public null|\UserHub\AdminV1\Connection $connection; + public null|Connection $connection; /** * The external identifier of the connected product. diff --git a/lib/AdminV1/Subscription.php b/lib/AdminV1/Subscription.php index 01ef782..68ddf5b 100644 --- a/lib/AdminV1/Subscription.php +++ b/lib/AdminV1/Subscription.php @@ -32,7 +32,7 @@ class Subscription implements \JsonSerializable, JsonUnserializable /** * The billing connection. */ - public null|\UserHub\AdminV1\Connection $connection; + public null|Connection $connection; /** * The external identifier of the subscription. @@ -42,7 +42,7 @@ class Subscription implements \JsonSerializable, JsonUnserializable /** * The plan. */ - public null|\UserHub\AdminV1\Plan $plan; + public null|Plan $plan; /** * The currency code for the subscription (e.g. `USD`). @@ -66,7 +66,7 @@ class Subscription implements \JsonSerializable, JsonUnserializable /** * The payment method. */ - public null|\UserHub\AdminV1\PaymentMethod $paymentMethod; + public null|PaymentMethod $paymentMethod; /** * Whether the subscription is scheduled to be canceled @@ -92,12 +92,12 @@ class Subscription implements \JsonSerializable, JsonUnserializable /** * The trial information for the subscription. */ - public null|\UserHub\AdminV1\SubscriptionTrial $trial; + public null|SubscriptionTrial $trial; /** * The current billing period for the subscription. */ - public null|\UserHub\AdminV1\SubscriptionCurrentPeriod $currentPeriod; + public null|SubscriptionCurrentPeriod $currentPeriod; /** * The organization owner of the subscription. @@ -105,7 +105,7 @@ class Subscription implements \JsonSerializable, JsonUnserializable * The ID field of this object must be populated if * if user isn't specified. */ - public null|\UserHub\AdminV1\Organization $organization; + public null|Organization $organization; /** * The user owner of the subscription. @@ -113,7 +113,7 @@ class Subscription implements \JsonSerializable, JsonUnserializable * The ID field of this object must be populated if * if organization isn't specified. */ - public null|\UserHub\AdminV1\User $user; + public null|User $user; /** * Whether the subscription is the default for the account. diff --git a/lib/AdminV1/SubscriptionItem.php b/lib/AdminV1/SubscriptionItem.php index f9a791a..beca73b 100644 --- a/lib/AdminV1/SubscriptionItem.php +++ b/lib/AdminV1/SubscriptionItem.php @@ -21,12 +21,12 @@ class SubscriptionItem implements \JsonSerializable, JsonUnserializable /** * The item product. */ - public null|\UserHub\AdminV1\Product $product; + public null|Product $product; /** * The item price. */ - public null|\UserHub\AdminV1\Price $price; + public null|Price $price; /** * The quantity of products. diff --git a/lib/AdminV1/SubscriptionSeatInfo.php b/lib/AdminV1/SubscriptionSeatInfo.php index 877ea81..83773ce 100644 --- a/lib/AdminV1/SubscriptionSeatInfo.php +++ b/lib/AdminV1/SubscriptionSeatInfo.php @@ -16,7 +16,7 @@ class SubscriptionSeatInfo implements \JsonSerializable, JsonUnserializable /** * The seat product. */ - public null|\UserHub\AdminV1\Product $product; + public null|Product $product; /** * The quantity which has been invoiced for the current billing period. diff --git a/lib/AdminV1/Trigger.php b/lib/AdminV1/Trigger.php index cac79a8..1c88438 100644 --- a/lib/AdminV1/Trigger.php +++ b/lib/AdminV1/Trigger.php @@ -18,7 +18,7 @@ class Trigger implements \JsonSerializable, JsonUnserializable /** * The connection. */ - public null|\UserHub\AdminV1\Connection $connection; + public null|Connection $connection; /** * The event type. diff --git a/lib/AdminV1/TriggerResult.php b/lib/AdminV1/TriggerResult.php index face91b..f70ff0b 100644 --- a/lib/AdminV1/TriggerResult.php +++ b/lib/AdminV1/TriggerResult.php @@ -17,12 +17,12 @@ class TriggerResult implements \JsonSerializable, JsonUnserializable /** * The trigger. */ - public null|\UserHub\AdminV1\Trigger $trigger; + public null|Trigger $trigger; /** * The trigger error. */ - public null|\UserHub\ApiV1\Status $error; + public null|Status $error; public function __construct( null|Trigger $trigger = null, diff --git a/lib/AdminV1/User.php b/lib/AdminV1/User.php index f8d716c..8a02e9e 100644 --- a/lib/AdminV1/User.php +++ b/lib/AdminV1/User.php @@ -88,7 +88,7 @@ class User implements \JsonSerializable, JsonUnserializable /** * The billing address for the user. */ - public null|\UserHub\CommonV1\Address $address; + public null|Address $address; /** * The connected accounts. @@ -103,7 +103,7 @@ class User implements \JsonSerializable, JsonUnserializable * See memberships for organization subscription and * seat information. */ - public null|\UserHub\AdminV1\AccountSubscription $subscription; + public null|AccountSubscription $subscription; /** * The user's organization memberships. diff --git a/lib/AdminV1/UserInput.php b/lib/AdminV1/UserInput.php index 2d96b2c..cf03a37 100644 --- a/lib/AdminV1/UserInput.php +++ b/lib/AdminV1/UserInput.php @@ -90,7 +90,7 @@ class UserInput implements \JsonSerializable, JsonUnserializable /** * The billing address for the user. */ - public null|\UserHub\CommonV1\Address $address; + public null|Address $address; /** * The sign-up time for the user. diff --git a/lib/AdminV1/UserResult.php b/lib/AdminV1/UserResult.php index 8e0a127..c613971 100644 --- a/lib/AdminV1/UserResult.php +++ b/lib/AdminV1/UserResult.php @@ -17,12 +17,12 @@ class UserResult implements \JsonSerializable, JsonUnserializable /** * The user. */ - public null|\UserHub\AdminV1\User $user; + public null|User $user; /** * The result error. */ - public null|\UserHub\ApiV1\Status $error; + public null|Status $error; public function __construct( null|User $user = null, diff --git a/lib/ApiV1/EmptyResponse.php b/lib/ApiV1/EmptyResponse.php index 50b6d37..27a18eb 100644 --- a/lib/ApiV1/EmptyResponse.php +++ b/lib/ApiV1/EmptyResponse.php @@ -1,7 +1,5 @@ 200, + Code::AlreadyExists, Code::FailedPrecondition, Code::InvalidArgument => 400, + Code::NotFound => 404, + Code::ResourceExhausted => 429, + Code::Unimplemented => 501, + default => 500, + }; + } +} diff --git a/lib/CommonV1/Any.php b/lib/CommonV1/Any.php index 83ebbc7..1ba7815 100644 --- a/lib/CommonV1/Any.php +++ b/lib/CommonV1/Any.php @@ -1,7 +1,5 @@ id = $id ?? null; + } + + public function jsonSerialize(): mixed + { + return (object) [ + 'id' => $this->id ?? null, + ]; + } + + public static function jsonUnserialize(mixed $data): static + { + if (!\is_object($data)) { + throw new \TypeError('json data must be an object'); + } + + return new self( + $data->{'id'} ?? null, + ); + } +} diff --git a/lib/ConnectionsV1/GetCustomUserRequest.php b/lib/ConnectionsV1/GetCustomUserRequest.php new file mode 100644 index 0000000..45af96c --- /dev/null +++ b/lib/ConnectionsV1/GetCustomUserRequest.php @@ -0,0 +1,44 @@ +id = $id ?? null; + } + + public function jsonSerialize(): mixed + { + return (object) [ + 'id' => $this->id ?? null, + ]; + } + + public static function jsonUnserialize(mixed $data): static + { + if (!\is_object($data)) { + throw new \TypeError('json data must be an object'); + } + + return new self( + $data->{'id'} ?? null, + ); + } +} diff --git a/lib/ConnectionsV1/ListCustomUsersRequest.php b/lib/ConnectionsV1/ListCustomUsersRequest.php new file mode 100644 index 0000000..40c7c5f --- /dev/null +++ b/lib/ConnectionsV1/ListCustomUsersRequest.php @@ -0,0 +1,57 @@ +pageSize = $pageSize ?? null; + $this->pageToken = $pageToken ?? null; + } + + public function jsonSerialize(): mixed + { + return (object) [ + 'pageSize' => $this->pageSize ?? null, + 'pageToken' => $this->pageToken ?? null, + ]; + } + + public static function jsonUnserialize(mixed $data): static + { + if (!\is_object($data)) { + throw new \TypeError('json data must be an object'); + } + + return new self( + $data->{'pageSize'} ?? null, + $data->{'pageToken'} ?? null, + ); + } +} diff --git a/lib/EventsV1/Event.php b/lib/EventsV1/Event.php index 3bd8d96..7af7282 100644 --- a/lib/EventsV1/Event.php +++ b/lib/EventsV1/Event.php @@ -32,27 +32,27 @@ class Event implements \JsonSerializable, JsonUnserializable /** * The `flows.changed` data. */ - public null|\UserHub\EventsV1\FlowsChanged $flowsChanged; + public null|FlowsChanged $flowsChanged; /** * The `members.changed` data. */ - public null|\UserHub\EventsV1\MembersChanged $membersChanged; + public null|MembersChanged $membersChanged; /** * The `organizations.changed` data. */ - public null|\UserHub\EventsV1\OrganizationsChanged $organizationsChanged; + public null|OrganizationsChanged $organizationsChanged; /** * The `subscriptions.changed` data. */ - public null|\UserHub\EventsV1\SubscriptionsChanged $subscriptionsChanged; + public null|SubscriptionsChanged $subscriptionsChanged; /** * The `users.changed` data. */ - public null|\UserHub\EventsV1\UsersChanged $usersChanged; + public null|UsersChanged $usersChanged; public function __construct( null|string $id = null, diff --git a/lib/EventsV1/FlowsChanged.php b/lib/EventsV1/FlowsChanged.php index 5af03ed..9feddb1 100644 --- a/lib/EventsV1/FlowsChanged.php +++ b/lib/EventsV1/FlowsChanged.php @@ -17,7 +17,7 @@ class FlowsChanged implements \JsonSerializable, JsonUnserializable /** * The flow. */ - public null|\UserHub\AdminV1\Flow $flow; + public null|Flow $flow; public function __construct( null|Flow $flow = null, diff --git a/lib/EventsV1/MembersChanged.php b/lib/EventsV1/MembersChanged.php index c06f0bb..8dd2e1e 100644 --- a/lib/EventsV1/MembersChanged.php +++ b/lib/EventsV1/MembersChanged.php @@ -18,12 +18,12 @@ class MembersChanged implements \JsonSerializable, JsonUnserializable /** * The organization. */ - public null|\UserHub\AdminV1\Organization $organization; + public null|Organization $organization; /** * The member. */ - public null|\UserHub\AdminV1\Member $member; + public null|Member $member; public function __construct( null|Organization $organization = null, diff --git a/lib/EventsV1/OrganizationsChanged.php b/lib/EventsV1/OrganizationsChanged.php index c534078..1db1e0b 100644 --- a/lib/EventsV1/OrganizationsChanged.php +++ b/lib/EventsV1/OrganizationsChanged.php @@ -17,7 +17,7 @@ class OrganizationsChanged implements \JsonSerializable, JsonUnserializable /** * The organization. */ - public null|\UserHub\AdminV1\Organization $organization; + public null|Organization $organization; public function __construct( null|Organization $organization = null, diff --git a/lib/EventsV1/SubscriptionsChanged.php b/lib/EventsV1/SubscriptionsChanged.php index c30d842..2fa9be0 100644 --- a/lib/EventsV1/SubscriptionsChanged.php +++ b/lib/EventsV1/SubscriptionsChanged.php @@ -17,7 +17,7 @@ class SubscriptionsChanged implements \JsonSerializable, JsonUnserializable /** * The subscription. */ - public null|\UserHub\AdminV1\Subscription $subscription; + public null|Subscription $subscription; public function __construct( null|Subscription $subscription = null, diff --git a/lib/EventsV1/UsersChanged.php b/lib/EventsV1/UsersChanged.php index c8c8e9f..811aec5 100644 --- a/lib/EventsV1/UsersChanged.php +++ b/lib/EventsV1/UsersChanged.php @@ -17,7 +17,7 @@ class UsersChanged implements \JsonSerializable, JsonUnserializable /** * The user. */ - public null|\UserHub\AdminV1\User $user; + public null|User $user; public function __construct( null|User $user = null, diff --git a/lib/Internal/Constants.php b/lib/Internal/Constants.php index aac2cd3..0a9005e 100644 --- a/lib/Internal/Constants.php +++ b/lib/Internal/Constants.php @@ -9,7 +9,8 @@ abstract class Constants { public const API_BASE_URL = 'https://api.userhub.com'; - public const VERSION = '0.2.0'; + public const USER_AGENT = 'UserHub-PHP/0.3.0'; + public const VERSION = '0.3.0'; public const AUTH_HEADER = 'Authorization'; public const API_KEY_HEADER = 'UserHub-Api-Key'; @@ -18,6 +19,14 @@ abstract class Constants public const SUMMARIZE_BODY_LENGTH = 20; + public const WEBHOOK_ACTION_HEADER = 'UserHub-Action'; + public const WEBHOOK_AGENT_HEADER = 'Webhook-Agent'; + public const WEBHOOK_MAX_REQUEST_SIZE_BYTES = 5242880; + public const WEBHOOK_MAX_TIMESTAMP_DIFF_MS = 300000; + public const WEBHOOK_SIGNATURE_HEADER = 'UserHub-Signature'; + public const WEBHOOK_TIMESTAMP_HEADER = 'UserHub-Timestamp'; + public const WEBHOOK_SERVER_ERROR_JSON = '{"message":"Webhook server error","code":"INTERNAL"}'; + public const CONNECT_TIMEOUT_MS = 30000; public const CONNECTION_IDLE_TIMEOUT_MS = 30000; public const MAX_BODY_SIZE_BYTES = 5242880; diff --git a/lib/Internal/CurlError.php b/lib/Internal/CurlError.php index bc9b171..fca2380 100644 --- a/lib/Internal/CurlError.php +++ b/lib/Internal/CurlError.php @@ -4,4 +4,7 @@ namespace UserHub\Internal; -class CurlError extends \Exception {} +/** + * @internal + */ +final class CurlError extends \Exception {} diff --git a/lib/Internal/CaseInsensitiveArray.php b/lib/Internal/Headers.php similarity index 73% rename from lib/Internal/CaseInsensitiveArray.php rename to lib/Internal/Headers.php index 06dd26b..1f83c89 100644 --- a/lib/Internal/CaseInsensitiveArray.php +++ b/lib/Internal/Headers.php @@ -27,18 +27,17 @@ namespace UserHub\Internal; /** - * CaseInsensitiveArray is an array-like class that ignores case for keys. + * @internal + * + * Headers is an array-like class that ignores case for keys. * * It is used to store HTTP headers. Per RFC 2616, section 4.2: * Each header field consists of a name followed by a colon (":") and the field value. Field names * are case-insensitive. - * - * In the context of stripe-php, this is useful because the API will return headers with different - * case depending on whether HTTP/2 is used or not (with HTTP/2, headers are always in lowercase). */ -class CaseInsensitiveArray implements \ArrayAccess, \Countable, \IteratorAggregate +final class Headers implements \ArrayAccess, \Countable, \IteratorAggregate { - private array $container = []; + private array $container; public function __construct(array $initial_array = []) { @@ -103,6 +102,46 @@ public function offsetGet($offset) return $this->container[$offset] ?? null; } + public function get($name): string + { + $value = self::offsetGet($name); + + if (\is_string($value)) { + return $value; + } + + if (\is_array($value)) { + foreach ($value as $v) { + if (\is_string($v)) { + return $v; + } + } + } + + return ''; + } + + public function getAll($name): array + { + $value = self::offsetGet($name); + + $headers = []; + + if (!empty($value)) { + if (\is_string($value)) { + $headers[] = $value; + } elseif (\is_array($value)) { + foreach ($value as $v) { + if (!empty($v) && \is_string($v)) { + $headers[] = $v; + } + } + } + } + + return $headers; + } + private static function maybeLowercase($v) { if (\is_string($v)) { diff --git a/lib/Internal/HttpTransport.php b/lib/Internal/HttpTransport.php index 6e200b3..121082e 100644 --- a/lib/Internal/HttpTransport.php +++ b/lib/Internal/HttpTransport.php @@ -5,19 +5,23 @@ namespace UserHub\Internal; use UserHub\ApiV1\Status; +use UserHub\Code; use UserHub\UserHubError; -class HttpTransport implements Transport +/** + * @internal + */ +final class HttpTransport implements Transport { private string $baseUrl; - private CaseInsensitiveArray $headers; + private Headers $headers; private ?\CurlHandle $_curlHandle; public function __construct(string $baseUrl, ?array $headers = null) { $this->baseUrl = $baseUrl; - $this->headers = new CaseInsensitiveArray(!empty($headers) ? $headers : []); - $this->headers['User-Agent'] = 'UserHub-Php/'.Constants::VERSION; + $this->headers = new Headers(!empty($headers) ? $headers : []); + $this->headers['User-Agent'] = Constants::USER_AGENT; } public function __destruct() @@ -42,7 +46,7 @@ public function execute(Request $req): Response $url .= '?'.implode('&', $query); } - $headers = new CaseInsensitiveArray(); + $headers = new Headers(); if (!empty($this->headers)) { foreach ($this->headers as $key => $value) { $headers[$key] = $value; @@ -57,7 +61,7 @@ public function execute(Request $req): Response $body = null; if (isset($req->body)) { $headers['content-type'] = 'application/json'; - $body = json_encode($req->body); + $body = json_encode($req->body, JSON_THROW_ON_ERROR); } while (true) { @@ -95,7 +99,7 @@ private function curlHandle(): \CurlHandle private function attempt( Request $req, string $url, - CaseInsensitiveArray $headers, + Headers $headers, ?string $body, ): Response { $opts = [ @@ -149,7 +153,7 @@ private function attempt( $opts[CURLOPT_POSTFIELDS] = $body; } - $resHeaders = new CaseInsensitiveArray(); + $resHeaders = new Headers(); $opts[CURLOPT_HEADERFUNCTION] = static function ($curl, $header) use (&$resHeaders) { $len = \strlen($header); @@ -195,7 +199,7 @@ private function attempt( $statusData = json_decode($resBody, flags: JSON_THROW_ON_ERROR); } catch (\Exception $e) { throw new UserHubError( - message: 'Failed to decode error response'.Response::summarizeBody($resBody), + message: 'Failed to decode error response'.Util::summarizeBody($resBody), call: $req->call, statusCode: $statusCode, previous: $e, @@ -204,11 +208,19 @@ private function attempt( $status = Status::jsonUnserialize($statusData); - throw new UserHubError(call: $req->call, status: $status, statusCode: $statusCode); + throw new UserHubError(call: $req->call, statusCode: $statusCode, status: $status); + } + if (429 === $statusCode) { + throw new UserHubError( + message: 'API call rate limited', + apiCode: Code::ResourceExhausted, + call: $req->call, + statusCode: $statusCode, + ); } throw new UserHubError( - message: 'API returned non-JSON error'.Response::summarizeBody($resBody), + message: 'API returned non-JSON error'.Util::summarizeBody($resBody), call: $req->call, statusCode: $statusCode, ); diff --git a/lib/Internal/JsonUnserializable.php b/lib/Internal/JsonUnserializable.php index 67b11d2..7d6f6fe 100644 --- a/lib/Internal/JsonUnserializable.php +++ b/lib/Internal/JsonUnserializable.php @@ -4,6 +4,9 @@ namespace UserHub\Internal; +/** + * @internal + */ interface JsonUnserializable { public static function jsonUnserialize(mixed $data): static; diff --git a/lib/Internal/Request.php b/lib/Internal/Request.php index 5737328..2501e3d 100644 --- a/lib/Internal/Request.php +++ b/lib/Internal/Request.php @@ -6,12 +6,15 @@ use UserHub\UserHubError; -class Request +/** + * @internal + */ +final class Request { public string $call; public string $method; public string $path; - public ?CaseInsensitiveArray $headers; + public ?Headers $headers; public ?array $query; public mixed $body; public int $attempt; @@ -37,7 +40,7 @@ public function setIdempotent(bool $idempotent): void public function setHeader(string $name, string $value): void { if (empty($this->headers)) { - $this->headers = new CaseInsensitiveArray(); + $this->headers = new Headers(); } $this->headers[$name] = $value; } diff --git a/lib/Internal/Response.php b/lib/Internal/Response.php index 95bf5f6..9d8669f 100644 --- a/lib/Internal/Response.php +++ b/lib/Internal/Response.php @@ -6,7 +6,10 @@ use UserHub\UserHubError; -class Response +/** + * @internal + */ +final class Response { public Request $req; public string $body; @@ -26,7 +29,7 @@ public function decodeBody(callable $callback): mixed $body = json_decode($this->body, flags: JSON_THROW_ON_ERROR); } catch (\Exception $e) { throw new UserHubError( - message: 'Failed to decode response'.$this->summarizeBody($this->body), + message: 'Failed to decode response'.Util::summarizeBody($this->body), call: $this->req->call, statusCode: 200, previous: $e, @@ -35,18 +38,4 @@ public function decodeBody(callable $callback): mixed return \call_user_func($callback, $body); } - - public static function summarizeBody(?string $body): string - { - if (empty($body)) { - return ''; - } - $body = trim(preg_replace('/\s+/', ' ', $body)); - if (empty($body)) { - return ''; - } - $body = substr($body, 0, Constants::SUMMARIZE_BODY_LENGTH * 2); - - return ': '.$body.'...'; - } } diff --git a/lib/Internal/TestTransport.php b/lib/Internal/TestTransport.php index b176faa..ffe3756 100644 --- a/lib/Internal/TestTransport.php +++ b/lib/Internal/TestTransport.php @@ -6,7 +6,10 @@ use UserHub\UserHubError; -class TestTransport implements Transport +/** + * @internal + */ +final class TestTransport implements Transport { public ?UserHubError $error; public ?Request $request; diff --git a/lib/Internal/Transport.php b/lib/Internal/Transport.php index 287159a..f10f2c3 100644 --- a/lib/Internal/Transport.php +++ b/lib/Internal/Transport.php @@ -6,6 +6,9 @@ use UserHub\UserHubError; +/** + * @internal + */ interface Transport { /** diff --git a/lib/Internal/Util.php b/lib/Internal/Util.php index 988c41f..3be8708 100644 --- a/lib/Internal/Util.php +++ b/lib/Internal/Util.php @@ -4,6 +4,9 @@ namespace UserHub\Internal; +/** + * @internal + */ abstract class Util { public static function emptyDateTime(): ?\DateTimeInterface @@ -58,4 +61,18 @@ public static function mapArray(?array $value, callable $callback): array return array_map($callback, $value); } + + public static function summarizeBody(?string $body): string + { + if (empty($body)) { + return ''; + } + $body = trim(preg_replace('/\s+/', ' ', $body)); + if (empty($body)) { + return ''; + } + $body = substr($body, 0, Constants::SUMMARIZE_BODY_LENGTH * 2); + + return ': '.$body.'...'; + } } diff --git a/lib/OperationsV1/Operation.php b/lib/OperationsV1/Operation.php index 11dd8e9..5e43c7b 100644 --- a/lib/OperationsV1/Operation.php +++ b/lib/OperationsV1/Operation.php @@ -30,12 +30,12 @@ class Operation implements \JsonSerializable, JsonUnserializable /** * The error result of the operation in case of failure. */ - public null|\UserHub\OperationsV1\OperationError $error; + public null|OperationError $error; /** * The normal response of the operation in case of success. */ - public null|\UserHub\CommonV1\Any $response; + public null|Any $response; /** * The creation time of the operation. diff --git a/lib/Undefined.php b/lib/Undefined.php index 57de747..3300266 100644 --- a/lib/Undefined.php +++ b/lib/Undefined.php @@ -4,7 +4,10 @@ namespace UserHub; -class Undefined +/** + * @internal + */ +final class Undefined { public static function is(mixed $v): bool { diff --git a/lib/UserApi/Flows.php b/lib/UserApi/Flows.php index f373d2a..5eae7e7 100644 --- a/lib/UserApi/Flows.php +++ b/lib/UserApi/Flows.php @@ -39,19 +39,19 @@ public function list( $req = new Request('user.flows.list', 'GET', '/user/v1/flows'); $req->setIdempotent(true); - if (isset($organizationId)) { + if (!empty($organizationId)) { $req->setQuery('organizationId', $organizationId); } - if (isset($type)) { + if (!empty($type)) { $req->setQuery('type', $type); } - if (isset($pageSize)) { + if (!empty($pageSize)) { $req->setQuery('pageSize', $pageSize); } - if (isset($pageToken)) { + if (!empty($pageToken)) { $req->setQuery('pageToken', $pageToken); } - if (isset($orderBy)) { + if (!empty($orderBy)) { $req->setQuery('orderBy', $orderBy); } @@ -76,16 +76,16 @@ public function createJoinOrganization( $req = new Request('user.flows.createJoinOrganization', 'POST', '/user/v1/flows:createJoinOrganization'); $body = []; - if (isset($organizationId)) { + if (!empty($organizationId)) { $body['organizationId'] = $organizationId; } - if (isset($userId)) { + if (!empty($userId)) { $body['userId'] = $userId; } - if (isset($email)) { + if (!empty($email)) { $body['email'] = $email; } - if (isset($displayName)) { + if (!empty($displayName)) { $body['displayName'] = $displayName; } diff --git a/lib/UserApi/Invoices.php b/lib/UserApi/Invoices.php index 183c0b4..00ab30f 100644 --- a/lib/UserApi/Invoices.php +++ b/lib/UserApi/Invoices.php @@ -38,16 +38,16 @@ public function list( $req = new Request('user.invoices.list', 'GET', '/user/v1/invoices'); $req->setIdempotent(true); - if (isset($organizationId)) { + if (!empty($organizationId)) { $req->setQuery('organizationId', $organizationId); } - if (isset($pageSize)) { + if (!empty($pageSize)) { $req->setQuery('pageSize', $pageSize); } - if (isset($pageToken)) { + if (!empty($pageToken)) { $req->setQuery('pageToken', $pageToken); } - if (isset($orderBy)) { + if (!empty($orderBy)) { $req->setQuery('orderBy', $orderBy); } diff --git a/lib/UserApi/Organizations.php b/lib/UserApi/Organizations.php index 55b39a9..ad890f5 100644 --- a/lib/UserApi/Organizations.php +++ b/lib/UserApi/Organizations.php @@ -39,13 +39,13 @@ public function list( $req = new Request('user.organizations.list', 'GET', '/user/v1/organizations'); $req->setIdempotent(true); - if (isset($pageSize)) { + if (!empty($pageSize)) { $req->setQuery('pageSize', $pageSize); } - if (isset($pageToken)) { + if (!empty($pageToken)) { $req->setQuery('pageToken', $pageToken); } - if (isset($orderBy)) { + if (!empty($orderBy)) { $req->setQuery('orderBy', $orderBy); } @@ -68,16 +68,16 @@ public function create( $req = new Request('user.organizations.create', 'POST', '/user/v1/organizations'); $body = []; - if (isset($uniqueId)) { + if (!empty($uniqueId)) { $body['uniqueId'] = $uniqueId; } - if (isset($displayName)) { + if (!empty($displayName)) { $body['displayName'] = $displayName; } - if (isset($email)) { + if (!empty($email)) { $body['email'] = $email; } - if (isset($flowId)) { + if (!empty($flowId)) { $body['flowId'] = $flowId; } diff --git a/lib/UserHubError.php b/lib/UserHubError.php index 3d8abeb..fcce60f 100644 --- a/lib/UserHubError.php +++ b/lib/UserHubError.php @@ -6,21 +6,25 @@ use UserHub\ApiV1\Status; -class UserHubError extends \Exception +class UserHubError extends \Exception implements \JsonSerializable { - protected ?string $apiCode; - protected ?string $reason; - protected ?string $param; - protected ?object $metadata; - protected ?string $call; - protected ?int $statusCode; + protected null|Code $apiCode = null; + protected null|string $reason = null; + protected null|string $param = null; + protected null|object $metadata = null; + protected null|string $call = null; + protected null|int $statusCode = null; public function __construct( - ?string $message = null, - ?string $call = null, - ?Status $status = null, - ?int $statusCode = null, - ?\Throwable $previous = null, + null|string $message = null, + null|Code $apiCode = null, + null|string $reason = null, + null|string $param = null, + null|object $metadata = null, + null|string $call = null, + null|int $statusCode = null, + null|Status $status = null, + null|\Throwable $previous = null, ) { if (empty($message)) { if (isset($status)) { @@ -33,19 +37,39 @@ public function __construct( parent::__construct($message, 0, $previous); - $this->call = empty($call) ? null : $call; - $this->statusCode = empty($statusCode) ? null : $statusCode; + if (!empty($call)) { + $this->call = $call; + } + if (!empty($statusCode)) { + $this->statusCode = $statusCode; + } if (isset($status)) { - $this->apiCode = $status->code; - $this->reason = empty($status->reason) ? null : $status->reason; - $this->param = empty($status->param) ? null : $status->param; - $this->metadata = empty($metadata) ? null : $metadata; - } else { - $this->apiCode = null; - $this->reason = null; - $this->param = null; - $this->metadata = null; + if (isset($status->code)) { + $this->apiCode = Code::tryFrom($status->code); + } + if (!empty($status->reason)) { + $this->reason = $status->reason; + } + if (!empty($status->param)) { + $this->param = $status->param; + } + if (!empty($metadata)) { + $this->metadata = $metadata; + } + } + + if (isset($apiCode)) { + $this->apiCode = $apiCode; + } + if (!empty($reason)) { + $status->reason = $reason; + } + if (!empty($param)) { + $status->param = $param; + } + if (!empty($metadata)) { + $status->metadata = $metadata; } } @@ -57,10 +81,10 @@ public function __toString(): string $parts[] = "call: {$this->call}"; } - $hasApiCode = isset($this->apiCode) && 'UNKNOWN' !== $this->apiCode; + $hasApiCode = isset($this->apiCode) && Code::Unknown !== $this->apiCode; if ($hasApiCode) { - $parts[] = "apiCode: {$this->apiCode}"; + $parts[] = "apiCode: {$this->apiCode->value}"; } if (!empty($this->reason)) { @@ -84,9 +108,9 @@ public function __toString(): string return $text; } - public function getApiCode(): string + public function getApiCode(): Code { - return empty($this->apiCode) ? 'UNKNOWN' : $this->apiCode; + return empty($this->apiCode) ? Code::Unknown : $this->apiCode; } public function getReason(): ?string @@ -117,4 +141,12 @@ public function getStatusCode(): ?int { return $this->statusCode; } + + public function jsonSerialize(): mixed + { + return (object) [ + 'code' => $this->getApiCode(), + 'message' => $this->getMessage(), + ]; + } } diff --git a/lib/UserV1/AccountSubscription.php b/lib/UserV1/AccountSubscription.php index deae444..6315a31 100644 --- a/lib/UserV1/AccountSubscription.php +++ b/lib/UserV1/AccountSubscription.php @@ -32,7 +32,7 @@ class AccountSubscription implements \JsonSerializable, JsonUnserializable /** * The subscription plan. */ - public null|\UserHub\UserV1\AccountSubscriptionPlan $plan; + public null|AccountSubscriptionPlan $plan; /** * The user's seat. @@ -40,7 +40,7 @@ class AccountSubscription implements \JsonSerializable, JsonUnserializable * This will only be set for organization subscriptions where * the user has been assigned a seat. */ - public null|\UserHub\UserV1\AccountSubscriptionSeat $seat; + public null|AccountSubscriptionSeat $seat; public function __construct( null|string $id = null, diff --git a/lib/UserV1/AccountSubscriptionPlan.php b/lib/UserV1/AccountSubscriptionPlan.php index 75041b1..9310854 100644 --- a/lib/UserV1/AccountSubscriptionPlan.php +++ b/lib/UserV1/AccountSubscriptionPlan.php @@ -26,7 +26,7 @@ class AccountSubscriptionPlan implements \JsonSerializable, JsonUnserializable /** * The plan product. */ - public null|\UserHub\UserV1\Product $product; + public null|Product $product; public function __construct( null|string $id = null, diff --git a/lib/UserV1/AccountSubscriptionSeat.php b/lib/UserV1/AccountSubscriptionSeat.php index 2811a88..dc5d76a 100644 --- a/lib/UserV1/AccountSubscriptionSeat.php +++ b/lib/UserV1/AccountSubscriptionSeat.php @@ -16,7 +16,7 @@ class AccountSubscriptionSeat implements \JsonSerializable, JsonUnserializable /** * The seat product. */ - public null|\UserHub\UserV1\Product $product; + public null|Product $product; public function __construct( null|Product $product = null, diff --git a/lib/UserV1/BillingAccount.php b/lib/UserV1/BillingAccount.php index 892f980..e3bb93a 100644 --- a/lib/UserV1/BillingAccount.php +++ b/lib/UserV1/BillingAccount.php @@ -45,7 +45,7 @@ class BillingAccount implements \JsonSerializable, JsonUnserializable /** * The subscription for the account. */ - public null|\UserHub\UserV1\Subscription $subscription; + public null|Subscription $subscription; public function __construct( null|string $state = null, diff --git a/lib/UserV1/CardPaymentMethod.php b/lib/UserV1/CardPaymentMethod.php index 92bdee4..695ad50 100644 --- a/lib/UserV1/CardPaymentMethod.php +++ b/lib/UserV1/CardPaymentMethod.php @@ -21,7 +21,7 @@ class CardPaymentMethod implements \JsonSerializable, JsonUnserializable /** * The expiration date of the card. */ - public null|\UserHub\UserV1\CardPaymentMethodExpiration $expiration; + public null|CardPaymentMethodExpiration $expiration; /** * The last for digits of the card. diff --git a/lib/UserV1/Flow.php b/lib/UserV1/Flow.php index 9c58347..283ce1c 100644 --- a/lib/UserV1/Flow.php +++ b/lib/UserV1/Flow.php @@ -37,17 +37,17 @@ class Flow implements \JsonSerializable, JsonUnserializable /** * The target organization for the flow. */ - public null|\UserHub\UserV1\Organization $organization; + public null|Organization $organization; /** * The target user for the flow. */ - public null|\UserHub\UserV1\User $user; + public null|User $user; /** * The user who created the flow. */ - public null|\UserHub\UserV1\User $creator; + public null|User $creator; /** * The time the flow will expires. @@ -62,12 +62,12 @@ class Flow implements \JsonSerializable, JsonUnserializable /** * The join organization flow type specific data. */ - public null|\UserHub\UserV1\JoinOrganizationFlow $joinOrganization; + public null|JoinOrganizationFlow $joinOrganization; /** * The signup flow type specific data. */ - public null|\UserHub\UserV1\SignupFlow $signup; + public null|SignupFlow $signup; public function __construct( null|string $id = null, diff --git a/lib/UserV1/Invoice.php b/lib/UserV1/Invoice.php index 9a2107a..e01bab8 100644 --- a/lib/UserV1/Invoice.php +++ b/lib/UserV1/Invoice.php @@ -48,7 +48,7 @@ class Invoice implements \JsonSerializable, JsonUnserializable /** * The contact information associated with the invoice. */ - public null|\UserHub\UserV1\InvoiceAccount $account; + public null|InvoiceAccount $account; /** * The time the invoice was finalized. @@ -58,7 +58,7 @@ class Invoice implements \JsonSerializable, JsonUnserializable /** * The billing period for the invoice. */ - public null|\UserHub\CommonV1\Period $period; + public null|Period $period; /** * The subtotal amount for the invoice. @@ -78,7 +78,7 @@ class Invoice implements \JsonSerializable, JsonUnserializable * The starting and ending account balance as * of the time the invoice was finalized. */ - public null|\UserHub\UserV1\InvoiceBalance $balance; + public null|InvoiceBalance $balance; /** * The tax amount for the invoice. @@ -122,7 +122,7 @@ class Invoice implements \JsonSerializable, JsonUnserializable /** * The payment intent for the invoice. */ - public null|\UserHub\UserV1\PaymentIntent $paymentIntent; + public null|PaymentIntent $paymentIntent; /** * The line items for the invoice. diff --git a/lib/UserV1/InvoiceAccount.php b/lib/UserV1/InvoiceAccount.php index f1af5b7..a465d32 100644 --- a/lib/UserV1/InvoiceAccount.php +++ b/lib/UserV1/InvoiceAccount.php @@ -32,7 +32,7 @@ class InvoiceAccount implements \JsonSerializable, JsonUnserializable /** * The billing address. */ - public null|\UserHub\CommonV1\Address $address; + public null|Address $address; public function __construct( null|string $fullName = null, diff --git a/lib/UserV1/InvoiceItem.php b/lib/UserV1/InvoiceItem.php index 22789e0..26e34e2 100644 --- a/lib/UserV1/InvoiceItem.php +++ b/lib/UserV1/InvoiceItem.php @@ -22,12 +22,12 @@ class InvoiceItem implements \JsonSerializable, JsonUnserializable /** * The details of the associated product. */ - public null|\UserHub\UserV1\Product $product; + public null|Product $product; /** * The details of the associated price. */ - public null|\UserHub\UserV1\Price $price; + public null|Price $price; /** * The quantity of the item product/price. @@ -58,7 +58,7 @@ class InvoiceItem implements \JsonSerializable, JsonUnserializable /** * The billing period for the item. */ - public null|\UserHub\CommonV1\Period $period; + public null|Period $period; public function __construct( null|string $id = null, diff --git a/lib/UserV1/InvoicePreview.php b/lib/UserV1/InvoicePreview.php index c298b65..0a83bf3 100644 --- a/lib/UserV1/InvoicePreview.php +++ b/lib/UserV1/InvoicePreview.php @@ -22,7 +22,7 @@ class InvoicePreview implements \JsonSerializable, JsonUnserializable /** * The contact information associated with the invoice. */ - public null|\UserHub\UserV1\InvoiceAccount $account; + public null|InvoiceAccount $account; /** * The time the upcoming invoice will be finalized. @@ -53,7 +53,7 @@ class InvoicePreview implements \JsonSerializable, JsonUnserializable * The starting and ending account balance as * of the time the invoice was finalized. */ - public null|\UserHub\UserV1\InvoiceBalance $balance; + public null|InvoiceBalance $balance; /** * The tax amount for the invoice. diff --git a/lib/UserV1/InvoicePreviewItem.php b/lib/UserV1/InvoicePreviewItem.php index 29e6ebd..b720d4f 100644 --- a/lib/UserV1/InvoicePreviewItem.php +++ b/lib/UserV1/InvoicePreviewItem.php @@ -17,12 +17,12 @@ class InvoicePreviewItem implements \JsonSerializable, JsonUnserializable /** * The details of the associated product. */ - public null|\UserHub\UserV1\Product $product; + public null|Product $product; /** * The details of the associated price. */ - public null|\UserHub\UserV1\Price $price; + public null|Price $price; /** * The quantity of the item product/price. @@ -53,7 +53,7 @@ class InvoicePreviewItem implements \JsonSerializable, JsonUnserializable /** * The billing period for the item. */ - public null|\UserHub\CommonV1\Period $period; + public null|Period $period; public function __construct( null|Product $product = null, diff --git a/lib/UserV1/Member.php b/lib/UserV1/Member.php index 411cc92..d2ab949 100644 --- a/lib/UserV1/Member.php +++ b/lib/UserV1/Member.php @@ -16,12 +16,12 @@ class Member implements \JsonSerializable, JsonUnserializable /** * The user. */ - public null|\UserHub\UserV1\User $user; + public null|User $user; /** * The user's role within the organization. */ - public null|\UserHub\UserV1\Role $role; + public null|Role $role; /** * The seat assigned to the member. @@ -30,7 +30,7 @@ class Member implements \JsonSerializable, JsonUnserializable * subscription for the organization or the user * has not been assigned a seat. */ - public null|\UserHub\UserV1\AccountSubscriptionSeat $seat; + public null|AccountSubscriptionSeat $seat; public function __construct( null|User $user = null, diff --git a/lib/UserV1/Membership.php b/lib/UserV1/Membership.php index 1cb7879..83522a9 100644 --- a/lib/UserV1/Membership.php +++ b/lib/UserV1/Membership.php @@ -19,17 +19,17 @@ class Membership implements \JsonSerializable, JsonUnserializable /** * The organization. */ - public null|\UserHub\UserV1\Organization $organization; + public null|Organization $organization; /** * The user's role within the organization. */ - public null|\UserHub\UserV1\Role $role; + public null|Role $role; /** * The subscription associated with the organization. */ - public null|\UserHub\UserV1\AccountSubscription $subscription; + public null|AccountSubscription $subscription; public function __construct( null|Organization $organization = null, diff --git a/lib/UserV1/PaymentIntent.php b/lib/UserV1/PaymentIntent.php index 5d37cd5..3957130 100644 --- a/lib/UserV1/PaymentIntent.php +++ b/lib/UserV1/PaymentIntent.php @@ -16,7 +16,7 @@ class PaymentIntent implements \JsonSerializable, JsonUnserializable /** * A Stripe payment intent. */ - public null|\UserHub\UserV1\StripePaymentIntent $stripe; + public null|StripePaymentIntent $stripe; public function __construct( null|StripePaymentIntent $stripe = null, diff --git a/lib/UserV1/PaymentMethod.php b/lib/UserV1/PaymentMethod.php index c40742f..b46cad7 100644 --- a/lib/UserV1/PaymentMethod.php +++ b/lib/UserV1/PaymentMethod.php @@ -41,7 +41,7 @@ class PaymentMethod implements \JsonSerializable, JsonUnserializable /** * The address for the payment method. */ - public null|\UserHub\CommonV1\Address $address; + public null|Address $address; /** * Whether the payment method is the default for the account. @@ -61,7 +61,7 @@ class PaymentMethod implements \JsonSerializable, JsonUnserializable /** * Card payment method (e.g. Visa credit card). */ - public null|\UserHub\UserV1\CardPaymentMethod $card; + public null|CardPaymentMethod $card; public function __construct( null|string $id = null, diff --git a/lib/UserV1/PaymentMethodIntent.php b/lib/UserV1/PaymentMethodIntent.php index 4b17b23..a30eca5 100644 --- a/lib/UserV1/PaymentMethodIntent.php +++ b/lib/UserV1/PaymentMethodIntent.php @@ -16,7 +16,7 @@ class PaymentMethodIntent implements \JsonSerializable, JsonUnserializable /** * A Stripe Setup Intent. */ - public null|\UserHub\UserV1\StripePaymentMethodIntent $stripe; + public null|StripePaymentMethodIntent $stripe; public function __construct( null|StripePaymentMethodIntent $stripe = null, diff --git a/lib/UserV1/Plan.php b/lib/UserV1/Plan.php index 2a4cb88..0bd0263 100644 --- a/lib/UserV1/Plan.php +++ b/lib/UserV1/Plan.php @@ -38,7 +38,7 @@ class Plan implements \JsonSerializable, JsonUnserializable /** * The billing interval for the plan. */ - public null|\UserHub\CommonV1\Interval $billingInterval; + public null|Interval $billingInterval; /** * The items associated with plan. diff --git a/lib/UserV1/PlanGroup.php b/lib/UserV1/PlanGroup.php index d832410..99d6b58 100644 --- a/lib/UserV1/PlanGroup.php +++ b/lib/UserV1/PlanGroup.php @@ -49,12 +49,12 @@ class PlanGroup implements \JsonSerializable, JsonUnserializable * For authenticated requests, this will not be set when the account * isn't eligible for a trial. */ - public null|\UserHub\UserV1\PlanGroupTrial $trial; + public null|PlanGroupTrial $trial; /** * Whether the plan is consider an downgrade/upgrade. */ - public null|\UserHub\UserV1\PlanGroupChangePath $changePath; + public null|PlanGroupChangePath $changePath; /** * The plans associated with plan group. diff --git a/lib/UserV1/PlanItem.php b/lib/UserV1/PlanItem.php index 1c0b35d..dc9f893 100644 --- a/lib/UserV1/PlanItem.php +++ b/lib/UserV1/PlanItem.php @@ -16,12 +16,12 @@ class PlanItem implements \JsonSerializable, JsonUnserializable /** * The product associated with the item. */ - public null|\UserHub\UserV1\Product $product; + public null|Product $product; /** * The price associated with them item. */ - public null|\UserHub\UserV1\Price $price; + public null|Price $price; /** * The plan item type. diff --git a/lib/UserV1/Price.php b/lib/UserV1/Price.php index 2cda5ca..cf73499 100644 --- a/lib/UserV1/Price.php +++ b/lib/UserV1/Price.php @@ -32,17 +32,17 @@ class Price implements \JsonSerializable, JsonUnserializable /** * The billing interval for the price. */ - public null|\UserHub\CommonV1\Interval $interval; + public null|Interval $interval; /** * The price is fixed per quantity. */ - public null|\UserHub\UserV1\PriceFixedPrice $fixed; + public null|PriceFixedPrice $fixed; /** * The price is dependent on the quantity. */ - public null|\UserHub\UserV1\PriceTieredPrice $tiered; + public null|PriceTieredPrice $tiered; public function __construct( null|string $id = null, diff --git a/lib/UserV1/PriceFixedPrice.php b/lib/UserV1/PriceFixedPrice.php index e12ba71..e025ba6 100644 --- a/lib/UserV1/PriceFixedPrice.php +++ b/lib/UserV1/PriceFixedPrice.php @@ -22,7 +22,7 @@ class PriceFixedPrice implements \JsonSerializable, JsonUnserializable /** * Whether to transform the quantity before multiplying amount. */ - public null|\UserHub\UserV1\PriceTransformQuantity $transformQuantity; + public null|PriceTransformQuantity $transformQuantity; public function __construct( null|string $amount = null, diff --git a/lib/UserV1/Session.php b/lib/UserV1/Session.php index aa19788..90ec1c1 100644 --- a/lib/UserV1/Session.php +++ b/lib/UserV1/Session.php @@ -19,7 +19,7 @@ class Session implements \JsonSerializable, JsonUnserializable * * This will be null if the user is not authenticated. */ - public null|\UserHub\UserV1\User $user; + public null|User $user; /** * The authenticated user's organization memberships. @@ -34,7 +34,7 @@ class Session implements \JsonSerializable, JsonUnserializable * See memberships for organization subscription and * seat information. */ - public null|\UserHub\UserV1\AccountSubscription $subscription; + public null|AccountSubscription $subscription; /** * The expiration time for the current session. diff --git a/lib/UserV1/Subscription.php b/lib/UserV1/Subscription.php index c733a8d..ad6e72f 100644 --- a/lib/UserV1/Subscription.php +++ b/lib/UserV1/Subscription.php @@ -32,12 +32,12 @@ class Subscription implements \JsonSerializable, JsonUnserializable /** * The subscription items. */ - public null|\UserHub\UserV1\Plan $plan; + public null|Plan $plan; /** * The payment method. */ - public null|\UserHub\UserV1\PaymentMethod $paymentMethod; + public null|PaymentMethod $paymentMethod; /** * The subscription is scheduled to be canceled at the end of the @@ -58,12 +58,12 @@ class Subscription implements \JsonSerializable, JsonUnserializable /** * The trial information for the subscription. */ - public null|\UserHub\UserV1\SubscriptionTrial $trial; + public null|SubscriptionTrial $trial; /** * The current billing period for the subscription. */ - public null|\UserHub\UserV1\SubscriptionCurrentPeriod $currentPeriod; + public null|SubscriptionCurrentPeriod $currentPeriod; /** * The subscription items. diff --git a/lib/UserV1/SubscriptionItem.php b/lib/UserV1/SubscriptionItem.php index 6f5fe8b..66e26e3 100644 --- a/lib/UserV1/SubscriptionItem.php +++ b/lib/UserV1/SubscriptionItem.php @@ -21,12 +21,12 @@ class SubscriptionItem implements \JsonSerializable, JsonUnserializable /** * The details of the associated product. */ - public null|\UserHub\UserV1\Product $product; + public null|Product $product; /** * The details of the associated price. */ - public null|\UserHub\UserV1\Price $price; + public null|Price $price; /** * The quantity for the item. diff --git a/lib/UserV1/SubscriptionSeatInfo.php b/lib/UserV1/SubscriptionSeatInfo.php index a1405fd..74c8fe9 100644 --- a/lib/UserV1/SubscriptionSeatInfo.php +++ b/lib/UserV1/SubscriptionSeatInfo.php @@ -16,7 +16,7 @@ class SubscriptionSeatInfo implements \JsonSerializable, JsonUnserializable /** * The subscription item product. */ - public null|\UserHub\UserV1\Product $product; + public null|Product $product; /** * The quantity which has been invoiced for the current billing period. diff --git a/lib/Webhook.php b/lib/Webhook.php new file mode 100644 index 0000000..1ba1c2a --- /dev/null +++ b/lib/Webhook.php @@ -0,0 +1,121 @@ +onAction('challenge', $handler); + } + + /** + * Registers a handler for the `events.handle` action. + * + * @param null|callable(\UserHub\EventsV1\Event): void $handler is the action handler + */ + public function onEvent(null|callable $handler): self + { + if (isset($handler)) { + $handler = new DecodeHandler($handler, Event::jsonUnserialize(...)); + } + + return $this->onAction('events.handle', $handler); + } + + /** + * Registers a handler for the `users.list` action. + * + * @param null|callable(\UserHub\ConnectionsV1\ListCustomUsersRequest): \UserHub\ConnectionsV1\ListCustomUsersResponse $handler is the action handler + */ + public function onListUsers(null|callable $handler): self + { + if (isset($handler)) { + $handler = new DecodeHandler($handler, ListCustomUsersRequest::jsonUnserialize(...)); + } + + return $this->onAction('users.list', $handler); + } + + /** + * Registers a handler for the `users.create` action. + * + * @param null|callable(\UserHub\ConnectionsV1\CustomUser): \UserHub\ConnectionsV1\CustomUser $handler is the action handler + */ + public function onCreateUser(null|callable $handler): self + { + if (isset($handler)) { + $handler = new DecodeHandler($handler, CustomUser::jsonUnserialize(...)); + } + + return $this->onAction('users.create', $handler); + } + + /** + * Registers a handler for the `users.get` action. + * + * @param null|callable(\UserHub\ConnectionsV1\GetCustomUserRequest): \UserHub\ConnectionsV1\CustomUser $handler is the action handler + */ + public function onGetUser(null|callable $handler): self + { + if (isset($handler)) { + $handler = new DecodeHandler($handler, GetCustomUserRequest::jsonUnserialize(...)); + } + + return $this->onAction('users.get', $handler); + } + + /** + * Registers a handler for the `users.update` action. + * + * @param null|callable(\UserHub\ConnectionsV1\CustomUser): \UserHub\ConnectionsV1\CustomUser $handler is the action handler + */ + public function onUpdateUser(null|callable $handler): self + { + if (isset($handler)) { + $handler = new DecodeHandler($handler, CustomUser::jsonUnserialize(...)); + } + + return $this->onAction('users.update', $handler); + } + + /** + * Registers a handler for the `users.delete` action. + * + * @param null|callable(\UserHub\ConnectionsV1\DeleteCustomUserRequest): void $handler is the action handler + */ + public function onDeleteUser(null|callable $handler): self + { + if (isset($handler)) { + $handler = new DecodeHandler($handler, DeleteCustomUserRequest::jsonUnserialize(...)); + } + + return $this->onAction('users.delete', $handler); + } +} diff --git a/lib/Webhook/BaseWebhook.php b/lib/Webhook/BaseWebhook.php new file mode 100644 index 0000000..72dd4d1 --- /dev/null +++ b/lib/Webhook/BaseWebhook.php @@ -0,0 +1,232 @@ +signingSecret = $signingSecret; + $this->handlers = []; + if (Undefined::is($onError)) { + $this->onError = self::defaultOnError(...); + } else { + $this->onError = isset($onError) ? $onError(...) : null; + } + } + + /** + * Executes a handler based on specified Request. + */ + public function __invoke(Request $req): Response + { + try { + $this->verify($req); + + $action = $req->getAction(); + + $handler = $this->handlers[$action] ?? null; + if (!isset($handler)) { + if ('challenge' === $action) { + return $this->challengeHandler($req); + } + + $handler = $this->handlers[''] ?? null; + if (!isset($handler)) { + return $this->unimplementedHandler($req); + } + } + + return \call_user_func($handler, $req); + } catch (\Exception $ex) { + return $this->createResponse($ex); + } + } + + /** + * Registers a handler for the specified action. + * + * @param string $name is the action name + * @param null|callable(string, string): string $handler is the action handler + */ + public function onAction(string $name, null|callable $handler): self + { + if (isset($handler)) { + $this->handlers[$name] = $handler; + } else { + if (\array_key_exists($name, $this->handlers)) { + unset($this->handlers[$name]); + } + } + + return $this; + } + + /** + * Registers a fallback action handler. + * + * @param null|callable(string, string): string $handler is the fallback handler + */ + public function onDefault(null|callable $handler): self + { + return $this->onAction('', $handler); + } + + /** + * Ensures the body matches the specified signature/timestamp and throws + * an error if verification fails. + * + * @throws UserHubError + */ + public function verify(Request $req): void + { + if (empty($this->signingSecret)) { + throw new UserHubError('Signing secret is required'); + } + if (empty($req->headers) || 0 === \count($req->headers)) { + throw new UserHubError('Headers are required'); + } + if (empty($req->body)) { + throw new UserHubError('Body is required'); + } + + $req->getAction(); + $timestamp = $req->getTimestamp(); + $signatures = $req->getSignatures(); + + if (!is_numeric($timestamp)) { + throw new UserHubError("Timestamp is invalid: {$timestamp}"); + } + + $diff = ((int) $timestamp - time()) * 1000; + if ($diff > Constants::WEBHOOK_MAX_TIMESTAMP_DIFF_MS) { + throw new UserHubError("Timestamp is too far in the future: {$timestamp}"); + } + if ($diff < -Constants::WEBHOOK_MAX_TIMESTAMP_DIFF_MS) { + throw new UserHubError("Timestamp is too far in the past: {$timestamp}"); + } + + $digest = hash_hmac('sha256', $timestamp.'.'.$req->body, $this->signingSecret); + + $matched = false; + + if (!empty($digest)) { + foreach ($signatures as $signature) { + if (hash_equals($signature, $digest)) { + $matched = true; + + break; + } + } + } + + if (!$matched) { + throw new UserHubError('Signatures do not match'); + } + } + + /** + * Creates a response from an object that can be encoded + * using json_encode or an Exception. + */ + public function createResponse(mixed $value): Response + { + if ($value instanceof \Exception) { + $this->tryOnError($value); + } + + return Util::createResponse($value); + } + + /** + * This handles an HTTP request from the global PHP environment. + * + * The headers are parsed from `getallheaders` or $_SERVER, the + * payload is read `php://input`, and the result is written to + * `php://output`. + */ + public function handleFromGlobals(): void + { + if (\function_exists('getallheaders')) { + $headers = getallheaders(); + } else { + $headers = []; + + foreach ($_SERVER as $name => $value) { + if (!str_starts_with($name, 'HTTP_')) { + continue; + } + $name = str_replace('_', '-', strtolower(substr($name, 5))); + $headers[$name] = $value; + } + } + + if ('POST' === $_SERVER['REQUEST_METHOD']) { + $body = file_get_contents('php://input', length: Constants::WEBHOOK_MAX_REQUEST_SIZE_BYTES + 1) ?: ''; + + if (\strlen($body) === Constants::WEBHOOK_MAX_REQUEST_SIZE_BYTES + 1) { + $res = $this->createResponse(new UserHubError('Request body exceeded max length')); + } else { + $res = $this(new Request($headers, $body)); + } + } else { + $res = $this->createResponse(new UserHubError('Request should be a POST: '.$_SERVER['REQUEST_METHOD'])); + } + + foreach ($res->headers as $name => $value) { + header("{$name}: {$value}"); + } + + http_response_code($res->statusCode); + + file_put_contents('php://output', $res->body); + } + + private function tryOnError(\Exception $ex): void + { + if (isset($this->onError)) { + try { + \call_user_func($this->onError, $ex); + } catch (\Exception) { + // ignore error + } + } + } + + private static function defaultOnError(\Exception $ex): void + { + error_log('UserHub webhook: '.$ex->getMessage()); + } + + private static function challengeHandler(Request $req): Response + { + return Util::createResponse($req->body); + } + + /** + * @throws UserHubError + */ + private static function unimplementedHandler(Request $req): Response + { + $name = $req->getAction(); + + throw new UserHubError("Handler not implemented: {$name}", apiCode: Code::Unimplemented); + } +} diff --git a/lib/Webhook/DecodeHandler.php b/lib/Webhook/DecodeHandler.php new file mode 100644 index 0000000..309f68f --- /dev/null +++ b/lib/Webhook/DecodeHandler.php @@ -0,0 +1,41 @@ +handler = $handler(...); + $this->decoder = $decoder(...); + } + + /** + * @throws UserHubError + */ + public function __invoke(Request $req): Response + { + try { + $data = \call_user_func($this->decoder, json_decode($req->body, flags: JSON_THROW_ON_ERROR)); + } catch (\Exception $e) { + throw new UserHubError( + message: 'Failed to decode request'.Util::summarizeBody($body), + previous: $e, + ); + } + + $data = \call_user_func($this->handler, $data); + + return Util::createResponse($data); + } +} diff --git a/lib/Webhook/Request.php b/lib/Webhook/Request.php new file mode 100644 index 0000000..a11afe6 --- /dev/null +++ b/lib/Webhook/Request.php @@ -0,0 +1,99 @@ +headers = new Headers($headers ?? []); + $this->body = $body ?? ''; + } + + /** + * @throws UserHubError + */ + public function getAction(): string + { + $name = $this->headers->get(Constants::WEBHOOK_ACTION_HEADER); + + if (empty($name)) { + throw new UserHubError(Constants::WEBHOOK_ACTION_HEADER.' header is missing'); + } + + return $name; + } + + /** + * @throws UserHubError + */ + public function getTimestamp(): string + { + $timestamp = $this->headers->get(Constants::WEBHOOK_TIMESTAMP_HEADER); + + if (empty($timestamp)) { + throw new UserHubError(Constants::WEBHOOK_TIMESTAMP_HEADER.' header is missing'); + } + + return $timestamp; + } + + /** + * @return string[] + * + * @throws UserHubError + */ + public function getSignatures(): array + { + $signatures = []; + + $headers = $this->headers->getAll(Constants::WEBHOOK_SIGNATURE_HEADER); + + if (empty($headers)) { + throw new UserHubError(Constants::WEBHOOK_SIGNATURE_HEADER.' header is missing'); + } + + foreach ($headers as $header) { + $header = trim($header); + if (empty($header)) { + continue; + } + + if (!str_contains($header, ',')) { + $signatures[] = $header; + + continue; + } + + $headerParts = explode(',', $header); + + foreach ($headerParts as $signature) { + $signature = trim($signature); + + if (!empty($signature)) { + $signatures[] = $signature; + } + } + } + + if (empty($signatures)) { + throw new UserHubError(Constants::WEBHOOK_SIGNATURE_HEADER.' header normalized to nothing'); + } + + return $signatures; + } +} diff --git a/lib/Webhook/Response.php b/lib/Webhook/Response.php new file mode 100644 index 0000000..02ff5d4 --- /dev/null +++ b/lib/Webhook/Response.php @@ -0,0 +1,27 @@ +statusCode = $statusCode ?? 200; + if (\is_object($headers)) { + $headers = (array) $headers; + } + $this->headers = new Headers($headers ?? []); + $this->body = $body ?? ''; + } +} diff --git a/lib/Webhook/Util.php b/lib/Webhook/Util.php new file mode 100644 index 0000000..5a8951b --- /dev/null +++ b/lib/Webhook/Util.php @@ -0,0 +1,56 @@ +getApiCode()->webhookStatusCode(); + $body = json_encode($value, JSON_THROW_ON_ERROR); + } catch (\Exception $ex) { + $statusCode = 500; + $body = Constants::WEBHOOK_SERVER_ERROR_JSON; + } + } else { + $statusCode = 500; + $body = Constants::WEBHOOK_SERVER_ERROR_JSON; + } + } else { + try { + $body = json_encode($value, JSON_THROW_ON_ERROR); + } catch (\Exception $ex) { + $statusCode = 500; + $body = Constants::WEBHOOK_SERVER_ERROR_JSON; + } + } + + return new Response( + statusCode: $statusCode, + headers: [ + 'Content-Type' => 'application/json', + 'Webhook-Agent' => Constants::USER_AGENT, + ], + body: empty($body) ? '{}' : $body, + ); + } +} diff --git a/tests/AdminApi/FlowsTest.php b/tests/AdminApi/FlowsTest.php index db0193b..9c488dc 100644 --- a/tests/AdminApi/FlowsTest.php +++ b/tests/AdminApi/FlowsTest.php @@ -44,8 +44,8 @@ public function testList(): void $res = $n->list(); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/flows', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/flows', $tr->request->path); } public function testCreateJoinOrganization(): void @@ -180,8 +180,8 @@ public function testCreateJoinOrganization(): void $res = $n->createJoinOrganization(); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/flows:createJoinOrganization', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/flows:createJoinOrganization', $tr->request->path); } public function testGet(): void @@ -316,8 +316,8 @@ public function testGet(): void $res = $n->get(flowId: 'flowId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/flows/flowId', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/flows/flowId', $tr->request->path); } public function testCancel(): void @@ -452,7 +452,7 @@ public function testCancel(): void $res = $n->cancel(flowId: 'flowId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/flows/flowId:cancel', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/flows/flowId:cancel', $tr->request->path); } } diff --git a/tests/AdminApi/InvoicesTest.php b/tests/AdminApi/InvoicesTest.php index e0375d1..5efbf86 100644 --- a/tests/AdminApi/InvoicesTest.php +++ b/tests/AdminApi/InvoicesTest.php @@ -55,8 +55,8 @@ public function testList(): void $res = $n->list(); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/invoices', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/invoices', $tr->request->path); } public function testGet(): void @@ -187,7 +187,7 @@ public function testGet(): void $res = $n->get(invoiceId: 'invoiceId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/invoices/invoiceId', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/invoices/invoiceId', $tr->request->path); } } diff --git a/tests/AdminApi/OrganizationsTest.php b/tests/AdminApi/OrganizationsTest.php index ff85650..63cdde6 100644 --- a/tests/AdminApi/OrganizationsTest.php +++ b/tests/AdminApi/OrganizationsTest.php @@ -52,8 +52,8 @@ public function testList(): void $res = $n->list(); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/organizations', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/organizations', $tr->request->path); } public function testCreate(): void @@ -118,8 +118,8 @@ public function testCreate(): void $res = $n->create(); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/organizations', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/organizations', $tr->request->path); } public function testGet(): void @@ -184,8 +184,8 @@ public function testGet(): void $res = $n->get(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId', $tr->request->path); } public function testUpdate(): void @@ -250,8 +250,8 @@ public function testUpdate(): void $res = $n->update(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('PATCH', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId', $tr->request->path); + self::assertEquals('PATCH', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId', $tr->request->path); } public function testDelete(): void @@ -316,8 +316,8 @@ public function testDelete(): void $res = $n->delete(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('DELETE', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId', $tr->request->path); + self::assertEquals('DELETE', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId', $tr->request->path); } public function testUndelete(): void @@ -382,8 +382,8 @@ public function testUndelete(): void $res = $n->undelete(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId:undelete', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId:undelete', $tr->request->path); } public function testConnect(): void @@ -448,8 +448,8 @@ public function testConnect(): void $res = $n->connect(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId:connect', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId:connect', $tr->request->path); } public function testDisconnect(): void @@ -514,8 +514,8 @@ public function testDisconnect(): void $res = $n->disconnect(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId:disconnect', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId:disconnect', $tr->request->path); } public function testListMembers(): void @@ -539,8 +539,8 @@ public function testListMembers(): void $res = $n->listMembers(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId/members', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId/members', $tr->request->path); } public function testAddMember(): void @@ -613,8 +613,8 @@ public function testAddMember(): void $res = $n->addMember(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId/members', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId/members', $tr->request->path); } public function testGetMember(): void @@ -687,8 +687,8 @@ public function testGetMember(): void $res = $n->getMember(organizationId: 'organizationId', userId: 'userId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId/members/userId', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId/members/userId', $tr->request->path); } public function testUpdateMember(): void @@ -761,8 +761,8 @@ public function testUpdateMember(): void $res = $n->updateMember(organizationId: 'organizationId', userId: 'userId'); self::assertNotNull($res); - self::assertSame('PATCH', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId/members/userId', $tr->request->path); + self::assertEquals('PATCH', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId/members/userId', $tr->request->path); } public function testRemoveMember(): void @@ -776,7 +776,7 @@ public function testRemoveMember(): void $res = $n->removeMember(organizationId: 'organizationId', userId: 'userId'); self::assertNotNull($res); - self::assertSame('DELETE', $tr->request->method); - self::assertSame('/admin/v1/organizations/organizationId/members/userId', $tr->request->path); + self::assertEquals('DELETE', $tr->request->method); + self::assertEquals('/admin/v1/organizations/organizationId/members/userId', $tr->request->path); } } diff --git a/tests/AdminApi/SubscriptionsTest.php b/tests/AdminApi/SubscriptionsTest.php index 49078e9..09a7c95 100644 --- a/tests/AdminApi/SubscriptionsTest.php +++ b/tests/AdminApi/SubscriptionsTest.php @@ -48,8 +48,8 @@ public function testList(): void $res = $n->list(); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/subscriptions', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/subscriptions', $tr->request->path); } public function testGet(): void @@ -253,7 +253,7 @@ public function testGet(): void $res = $n->get(subscriptionId: 'subscriptionId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/subscriptions/subscriptionId', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/subscriptions/subscriptionId', $tr->request->path); } } diff --git a/tests/AdminApi/UsersTest.php b/tests/AdminApi/UsersTest.php index 5ae0a13..2f9eede 100644 --- a/tests/AdminApi/UsersTest.php +++ b/tests/AdminApi/UsersTest.php @@ -52,8 +52,8 @@ public function testList(): void $res = $n->list(); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/users', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/users', $tr->request->path); } public function testCreate(): void @@ -124,8 +124,8 @@ public function testCreate(): void $res = $n->create(); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/users', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/users', $tr->request->path); } public function testGet(): void @@ -196,8 +196,8 @@ public function testGet(): void $res = $n->get(userId: 'userId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/admin/v1/users/userId', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/admin/v1/users/userId', $tr->request->path); } public function testUpdate(): void @@ -268,8 +268,8 @@ public function testUpdate(): void $res = $n->update(userId: 'userId'); self::assertNotNull($res); - self::assertSame('PATCH', $tr->request->method); - self::assertSame('/admin/v1/users/userId', $tr->request->path); + self::assertEquals('PATCH', $tr->request->method); + self::assertEquals('/admin/v1/users/userId', $tr->request->path); } public function testDelete(): void @@ -340,8 +340,8 @@ public function testDelete(): void $res = $n->delete(userId: 'userId'); self::assertNotNull($res); - self::assertSame('DELETE', $tr->request->method); - self::assertSame('/admin/v1/users/userId', $tr->request->path); + self::assertEquals('DELETE', $tr->request->method); + self::assertEquals('/admin/v1/users/userId', $tr->request->path); } public function testUndelete(): void @@ -412,8 +412,8 @@ public function testUndelete(): void $res = $n->undelete(userId: 'userId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/users/userId:undelete', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/users/userId:undelete', $tr->request->path); } public function testConnect(): void @@ -484,8 +484,8 @@ public function testConnect(): void $res = $n->connect(userId: 'userId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/users/userId:connect', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/users/userId:connect', $tr->request->path); } public function testDisconnect(): void @@ -556,8 +556,8 @@ public function testDisconnect(): void $res = $n->disconnect(userId: 'userId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/users/userId:disconnect', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/users/userId:disconnect', $tr->request->path); } public function testImportAccount(): void @@ -628,8 +628,8 @@ public function testImportAccount(): void $res = $n->importAccount(userId: 'userId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/users/userId:import', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/users/userId:import', $tr->request->path); } public function testCreateApiSession(): void @@ -646,8 +646,8 @@ public function testCreateApiSession(): void $res = $n->createApiSession(userId: 'userId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/users/userId:createApiSession', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/users/userId:createApiSession', $tr->request->path); } public function testCreatePortalSession(): void @@ -663,7 +663,7 @@ public function testCreatePortalSession(): void $res = $n->createPortalSession(userId: 'userId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/admin/v1/users/userId:createPortalSession', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/admin/v1/users/userId:createPortalSession', $tr->request->path); } } diff --git a/tests/BasicsTest.php b/tests/BasicsTest.php new file mode 100644 index 0000000..cd0d20f --- /dev/null +++ b/tests/BasicsTest.php @@ -0,0 +1,252 @@ +start(); + + self::$adminApi = new AdminApi('sk_test', baseUrl: self::$server->getServerRoot()); + } + + public static function tearDownAfterClass(): void + { + self::$server->stop(); + } + + public function getEnv(string $name): string + { + $value = getenv($name); + if (empty($value)) { + self::markTestSkipped("{$name} not configured"); + } + + return "{$value}"; + } + + public function testAdminApi(): void + { + $adminApi = new AdminApi(self::getEnv('TEST_ADMIN_KEY')); + $res = $adminApi->users->list(); + self::assertNotNull($res); + } + + public function testUserApi(): void + { + $userApi = new UserApi(self::getEnv('TEST_USER_KEY')); + $session = $userApi->session->get(); + self::assertNotNull($session); + } + + public function testModel(): void + { + $user = new User( + id: 'usr_1', + displayName: 'Jane Doe', + accountConnections: [ + new AccountConnection(externalId: 'cus_1'), + ], + createTime: new \DateTime(timezone: new \DateTimeZone('UTC')), + ); + self::assertSame('Jane Doe', $user->displayName); + + $data = $user->jsonSerialize(); + self::assertObjectHasProperty('displayName', $data); + self::assertSame('Jane Doe', $data->displayName); + + $encodedJson = json_encode($data); + $decodedUser = User::jsonUnserialize(json_decode($encodedJson)); + self::assertEquals($user, $decodedUser); + } + + public function testApiGet(): void + { + self::$server->setResponseOfPath( + '/admin/v1/users/usr_1', + new Response( + body: '{"id": "usr_1", "displayName": "Jane Doe"}', + headers: ['content-type' => 'application/json'], + ), + ); + + $res = self::$adminApi->users->get('usr_1'); + self::assertEquals('usr_1', $res->id); + self::assertEquals('Jane Doe', $res->displayName); + + $req = self::$server->getLastRequest(); + self::assertEquals('GET', $req->getRequestMethod()); + self::assertEquals('Bearer sk_test', $req->getHeaders()['authorization']); + } + + public function testApiPost(): void + { + self::$server->setResponseOfPath( + '/admin/v1/users', + new Response( + body: '{"id": "usr_1", "displayName": "Jane Doe"}', + headers: ['content-type' => 'application/json'], + ), + ); + + $res = self::$adminApi->users->create(displayName: 'Jane Doe'); + self::assertEquals('usr_1', $res->id); + self::assertEquals('Jane Doe', $res->displayName); + + $req = self::$server->getLastRequest(); + self::assertEquals('POST', $req->getRequestMethod()); + self::assertEquals('Bearer sk_test', $req->getHeaders()['authorization']); + self::assertEquals('{"displayName":"Jane Doe"}', $req->getInput()); + + self::$server->setResponseOfPath( + '/admin/v1/users', + new Response( + body: '{"id": "usr_1"}', + headers: ['content-type' => 'application/json'], + ), + ); + + $res = self::$adminApi->users->create(displayName: ''); + self::assertEquals('usr_1', $res->id); + self::assertEmpty($res->displayName); + + $req = self::$server->getLastRequest(); + self::assertEquals('{}', $req->getInput()); + } + + public function testApiPatch(): void + { + self::$server->setResponseOfPath( + '/admin/v1/users/usr_1', + new Response( + body: '{"id": "usr_1", "displayName": "Jane Doe"}', + headers: ['content-type' => 'application/json'], + ), + ); + + $res = self::$adminApi->users->update('usr_1', displayName: 'Jane Doe'); + self::assertEquals('usr_1', $res->id); + self::assertEquals('Jane Doe', $res->displayName); + + $req = self::$server->getLastRequest(); + self::assertEquals('PATCH', $req->getRequestMethod()); + self::assertEquals('Bearer sk_test', $req->getHeaders()['authorization']); + self::assertEquals('{"displayName":"Jane Doe"}', $req->getInput()); + + self::$server->setResponseOfPath( + '/admin/v1/users/usr_1', + new Response( + body: '{"id": "usr_1"}', + headers: ['content-type' => 'application/json'], + ), + ); + + $res = self::$adminApi->users->update('usr_1', displayName: ''); + self::assertEquals('usr_1', $res->id); + self::assertEmpty($res->displayName); + + $req = self::$server->getLastRequest(); + self::assertEquals('{"displayName":""}', $req->getInput()); + } + + public function testApiDelete(): void + { + self::$server->setResponseOfPath( + '/admin/v1/users/usr_1', + new Response( + body: '{"id": "usr_1", "displayName": "Jane Doe"}', + headers: ['content-type' => 'application/json'], + ), + ); + + $res = self::$adminApi->users->delete('usr_1'); + self::assertEquals('usr_1', $res->id); + self::assertEquals('Jane Doe', $res->displayName); + + $req = self::$server->getLastRequest(); + self::assertEquals('DELETE', $req->getRequestMethod()); + self::assertEquals('Bearer sk_test', $req->getHeaders()['authorization']); + } + + public function testApiError(): void + { + self::$server->setResponseOfPath( + '/admin/v1/users/usr_1', + new Response( + body: '{"code":"NOT_FOUND", "message":"Not Found", "metadata":{}}', + headers: ['content-type' => 'application/json'], + status: 404, + ), + ); + + $this->expectException(UserHubError::class); + + try { + self::$adminApi->users->get('usr_1'); + } catch (UserHubError $ex) { + self::assertEquals('UserHubError: Not Found (call: admin.users.get, apiCode: NOT_FOUND)', "{$ex}"); + self::assertEquals(Code::NotFound, $ex->getApiCode()); + self::assertEquals('Not Found', $ex->getMessage()); + self::assertEquals(404, $ex->getStatusCode()); + + throw $ex; + } + } + + public function testApiRateLimited(): void + { + if (empty(getenv('CI'))) { + self::markTestSkipped('Skipping slow test'); + } + + self::$server->setResponseOfPath( + '/admin/v1/users/usr_1', + new Response( + body: '', + headers: ['content-type' => 'application/json'], + status: 429, + ), + ); + + $this->expectException(UserHubError::class); + + $startTime = time(); + + try { + self::$adminApi->users->get('usr_1'); + } catch (UserHubError $ex) { + $endTime = time(); + + self::assertEquals('UserHubError: API call rate limited '. + '(call: admin.users.get, apiCode: RESOURCE_EXHAUSTED)', "{$ex}"); + self::assertEquals(Code::ResourceExhausted, $ex->getApiCode()); + self::assertEquals('API call rate limited', $ex->getMessage()); + self::assertEquals(429, $ex->getStatusCode()); + + $diff = $endTime - $startTime; + + self::assertGreaterThanOrEqual(2, $diff); + self::assertLessThanOrEqual(4, $diff); + + throw $ex; + } + } +} diff --git a/tests/UserApi/FlowsTest.php b/tests/UserApi/FlowsTest.php index a677251..0011268 100644 --- a/tests/UserApi/FlowsTest.php +++ b/tests/UserApi/FlowsTest.php @@ -40,8 +40,8 @@ public function testList(): void $res = $n->list(); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/user/v1/flows', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/user/v1/flows', $tr->request->path); } public function testCreateJoinOrganization(): void @@ -98,8 +98,8 @@ public function testCreateJoinOrganization(): void $res = $n->createJoinOrganization(); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/user/v1/flows:createJoinOrganization', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/user/v1/flows:createJoinOrganization', $tr->request->path); } public function testGet(): void @@ -156,8 +156,8 @@ public function testGet(): void $res = $n->get(flowId: 'flowId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/user/v1/flows/flowId', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/user/v1/flows/flowId', $tr->request->path); } public function testConsume(): void @@ -214,8 +214,8 @@ public function testConsume(): void $res = $n->consume(flowId: 'flowId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/user/v1/flows/flowId:consume', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/user/v1/flows/flowId:consume', $tr->request->path); } public function testCancel(): void @@ -272,7 +272,7 @@ public function testCancel(): void $res = $n->cancel(flowId: 'flowId'); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/user/v1/flows/flowId:cancel', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/user/v1/flows/flowId:cancel', $tr->request->path); } } diff --git a/tests/UserApi/InvoicesTest.php b/tests/UserApi/InvoicesTest.php index 7f5f972..bd939f6 100644 --- a/tests/UserApi/InvoicesTest.php +++ b/tests/UserApi/InvoicesTest.php @@ -52,8 +52,8 @@ public function testList(): void $res = $n->list(); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/user/v1/invoices', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/user/v1/invoices', $tr->request->path); } public function testGet(): void @@ -136,7 +136,7 @@ public function testGet(): void $res = $n->get(invoiceId: 'invoiceId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/user/v1/invoices/invoiceId', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/user/v1/invoices/invoiceId', $tr->request->path); } } diff --git a/tests/UserApi/OrganizationsTest.php b/tests/UserApi/OrganizationsTest.php index 212fda6..3a88cfd 100644 --- a/tests/UserApi/OrganizationsTest.php +++ b/tests/UserApi/OrganizationsTest.php @@ -41,8 +41,8 @@ public function testList(): void $res = $n->list(); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/user/v1/organizations', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/user/v1/organizations', $tr->request->path); } public function testCreate(): void @@ -64,8 +64,8 @@ public function testCreate(): void $res = $n->create(); self::assertNotNull($res); - self::assertSame('POST', $tr->request->method); - self::assertSame('/user/v1/organizations', $tr->request->path); + self::assertEquals('POST', $tr->request->method); + self::assertEquals('/user/v1/organizations', $tr->request->path); } public function testGet(): void @@ -87,8 +87,8 @@ public function testGet(): void $res = $n->get(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/user/v1/organizations/organizationId', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/user/v1/organizations/organizationId', $tr->request->path); } public function testUpdate(): void @@ -110,8 +110,8 @@ public function testUpdate(): void $res = $n->update(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('PATCH', $tr->request->method); - self::assertSame('/user/v1/organizations/organizationId', $tr->request->path); + self::assertEquals('PATCH', $tr->request->method); + self::assertEquals('/user/v1/organizations/organizationId', $tr->request->path); } public function testDelete(): void @@ -133,8 +133,8 @@ public function testDelete(): void $res = $n->delete(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('DELETE', $tr->request->method); - self::assertSame('/user/v1/organizations/organizationId', $tr->request->path); + self::assertEquals('DELETE', $tr->request->method); + self::assertEquals('/user/v1/organizations/organizationId', $tr->request->path); } public function testLeave(): void @@ -148,7 +148,7 @@ public function testLeave(): void $res = $n->leave(organizationId: 'organizationId'); self::assertNotNull($res); - self::assertSame('DELETE', $tr->request->method); - self::assertSame('/user/v1/organizations/organizationId:leave', $tr->request->path); + self::assertEquals('DELETE', $tr->request->method); + self::assertEquals('/user/v1/organizations/organizationId:leave', $tr->request->path); } } diff --git a/tests/UserApi/SessionTest.php b/tests/UserApi/SessionTest.php index d602795..9aea053 100644 --- a/tests/UserApi/SessionTest.php +++ b/tests/UserApi/SessionTest.php @@ -54,7 +54,7 @@ public function testGet(): void $res = $n->get(); self::assertNotNull($res); - self::assertSame('GET', $tr->request->method); - self::assertSame('/user/v1/session', $tr->request->path); + self::assertEquals('GET', $tr->request->method); + self::assertEquals('/user/v1/session', $tr->request->path); } } diff --git a/tests/WebhookTest.php b/tests/WebhookTest.php new file mode 100644 index 0000000..cb45e75 --- /dev/null +++ b/tests/WebhookTest.php @@ -0,0 +1,385 @@ +onEvent(static function (Event $event): void { + if ('ok' !== $event->type) { + throw new UserHubError("Event failed: {$event->type}", apiCode: Code::InvalidArgument); + } + }); + + if ($setTimestamp) { + $request->headers['UserHub-Timestamp'] = (string) time(); + } + if ($setSignature || $addSignature) { + $timestamp = $request->headers['UserHub-Timestamp']; + + $signature = hash_hmac('sha256', $timestamp.'.'.$request->body, $secret); + + if ($setSignature) { + $header = $request->headers['UserHub-Signature'] ?: ''; + + if (!str_contains($header, '{signature}')) { + $header = str_replace('{signature}', $signature, $header); + } else { + $header = $signature; + } + + $request->headers['UserHub-Signature'] = $header; + } else { + $header = $request->headers['UserHub-Signature']; + + if (is_array($header)) { + $header[] = $signature; + } elseif (is_string($header)) { + $header = [$header, $signature]; + } else { + $header = $signature; + } + + $request->headers['UserHub-Signature'] = $header; + } + } + + $res = $wh($request); + + self::assertEquals($response->statusCode, $res->statusCode, $res->body ?: '{}'); + + self::assertEquals($res->headers['content-type'], 'application/json'); + self::assertEquals($res->headers[Constants::WEBHOOK_AGENT_HEADER], Constants::USER_AGENT); + + $expected = json_decode($response->body ?: '{}', flags: JSON_THROW_ON_ERROR); + $actual = json_decode($res->body ?: '{}', flags: JSON_THROW_ON_ERROR); + + self::assertEquals($expected, $actual); + } + + public static function provideHandleCases(): iterable + { + return [ + [ + 'Signing secret is required', + '', + new Request( + ), + new Response( + statusCode: 500, + body: '{"message":"Signing secret is required","code":"UNKNOWN"}', + ), + false, + false, + false, + ], + [ + 'Headers are required', + 'test', + new Request( + ), + new Response( + statusCode: 500, + body: '{"message":"Headers are required","code":"UNKNOWN"}', + ), + false, + false, + false, + ], + [ + 'Body is required', + 'test', + new Request( + headers: [ + 'content-type' => 'application/json', + ], + ), + new Response( + statusCode: 500, + body: '{"message":"Body is required","code":"UNKNOWN"}', + ), + false, + false, + false, + ], + [ + 'UserHub-Action header is missing', + 'test', + new Request( + headers: [ + 'content-type' => 'application/json', + ], + body: '{}', + ), + new Response( + statusCode: 500, + body: '{"message":"UserHub-Action header is missing","code":"UNKNOWN"}', + ), + false, + false, + false, + ], + [ + 'UserHub-Timestamp header is missing', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'events.handle', + ], + body: '{}', + ), + new Response( + statusCode: 500, + body: '{"message":"UserHub-Timestamp header is missing","code":"UNKNOWN"}', + ), + false, + false, + false, + ], + [ + 'UserHub-Signature header is missing', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'events.handle', + ], + body: '{}', + ), + new Response( + statusCode: 500, + body: '{"message":"UserHub-Signature header is missing","code":"UNKNOWN"}', + ), + true, + false, + false, + ], + [ + 'Signatures normalized to nothing', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'events.handle', + 'UserHub-Signature' => ',', + ], + body: '{}', + ), + new Response( + statusCode: 500, + body: '{"message":"UserHub-Signature header normalized to nothing","code":"UNKNOWN"}', + ), + true, + false, + false, + ], + [ + 'Timestamp is invalid', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'events.handle', + 'UserHub-Timestamp' => 'timestamp', + 'UserHub-Signature' => 'fail', + ], + body: '{}', + ), + new Response( + statusCode: 500, + body: '{"message":"Timestamp is invalid: timestamp","code":"UNKNOWN"}', + ), + false, + false, + false, + ], + [ + 'Timestamp is too far in the past', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'events.handle', + 'UserHub-Timestamp' => '1', + 'UserHub-Signature' => 'fail', + ], + body: '{}', + ), + new Response( + statusCode: 500, + body: '{"message":"Timestamp is too far in the past: 1","code":"UNKNOWN"}', + ), + false, + false, + false, + ], + [ + 'Timestamp is too far in the past', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'events.handle', + 'UserHub-Timestamp' => '5000000000', + 'UserHub-Signature' => 'fail', + ], + body: '{}', + ), + new Response( + statusCode: 500, + body: '{"message":"Timestamp is too far in the future: 5000000000","code":"UNKNOWN"}', + ), + false, + false, + false, + ], + [ + 'Signatures do not match', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'events.handle', + 'UserHub-Signature' => 'fail', + ], + body: '{}', + ), + new Response( + statusCode: 500, + body: '{"message":"Signatures do not match","code":"UNKNOWN"}', + ), + true, + false, + false, + ], + [ + 'Challenge', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'challenge', + ], + body: '{"challenge": "some-value"}', + ), + new Response( + statusCode: 200, + body: '{"challenge": "some-value"}', + ), + true, + false, + true, + ], + [ + 'Handle multiple signature headers', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'challenge', + 'UserHub-Signature' => 'fail', + ], + body: '{"challenge": "some-value"}', + ), + new Response( + statusCode: 200, + body: '{"challenge": "some-value"}', + ), + true, + false, + true, + ], + [ + 'Handle combined signature headers', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'challenge', + 'UserHub-Signature' => 'fail, {signature}', + ], + body: '{"challenge": "some-value"}', + ), + new Response( + statusCode: 200, + body: '{"challenge": "some-value"}', + ), + true, + true, + false, + ], + [ + 'Handler not implemented', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'unknown', + ], + body: '{}', + ), + new Response( + statusCode: 501, + body: '{"message":"Handler not implemented: unknown","code":"UNIMPLEMENTED"}', + ), + true, + false, + true, + ], + [ + 'Event handler succeeds', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'events.handle', + ], + body: '{"type": "ok"}', + ), + new Response( + statusCode: 200, + body: '{}', + ), + true, + false, + true, + ], + [ + 'Event handler errors', + 'test', + new Request( + headers: [ + 'UserHub-Action' => 'events.handle', + ], + body: '{"type": "fail"}', + ), + new Response( + statusCode: 400, + body: '{"message":"Event failed: fail","code":"INVALID_ARGUMENT"}', + ), + true, + false, + true, + ], + ]; + } +}