diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..174e912
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,20 @@
+; https://editorconfig.org/
+
+root = true
+
+[*]
+indent_style = space
+indent_size = 4
+insert_final_newline = true
+trim_trailing_whitespace = true
+end_of_line = lf
+charset = utf-8
+
+[*.{json,yml}]
+indent_size = 2
+
+[*.neon]
+indent_style = tab
+
+[Makefile]
+indent_style = tab
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..65f5201
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,11 @@
+# https://git-scm.com/book/en/v2/Customizing-Git-Git-Attributes
+
+# A list of files and folders those will be excluded from archives and the
+# Composer package (for purposes of making it smaller).
+/.editorconfig export-ignore
+/.gitattributes export-ignore
+/.github export-ignore
+/.php-cs-fixer.php export-ignore
+/.vscode export-ignore
+/phpunit.xml export-ignore
+/tests export-ignore
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 0000000..5d3250e
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,77 @@
+name: Bug report
+description: Create a report to help us improve
+labels: ["bug"]
+body:
+- type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this bug report!
+- type: textarea
+ id: what-happened
+ attributes:
+ label: Describe the bug
+ description: A clear and concise description of what the bug is.
+ placeholder: Tell us what you see!
+ validations:
+ required: true
+- type: textarea
+ id: repro-steps
+ attributes:
+ label: To Reproduce
+ description: Steps to reproduce the behavior
+ placeholder: |
+ 1. Fetch a '...'
+ 2. Update the '....'
+ 3. See error
+ validations:
+ required: true
+- type: textarea
+ id: expected-behavior
+ attributes:
+ label: Expected behavior
+ description: A clear and concise description of what you expected to happen.
+ validations:
+ required: true
+- type: textarea
+ id: code-snippets
+ attributes:
+ label: Code snippets
+ description: If applicable, add code snippets to help explain your problem.
+ render: PHP
+ validations:
+ required: false
+- type: input
+ id: os
+ attributes:
+ label: OS
+ placeholder: macOS
+ validations:
+ required: true
+- type: input
+ id: language-version
+ attributes:
+ label: PHP version
+ placeholder: PHP 8.2
+ validations:
+ required: true
+- type: input
+ id: lib-version
+ attributes:
+ label: Library version
+ placeholder: tiendanube-php-sdk v2.0.0
+ validations:
+ required: true
+- type: input
+ id: api-version
+ attributes:
+ label: API version
+ placeholder: v1
+ validations:
+ required: true
+- type: textarea
+ id: additional-context
+ attributes:
+ label: Additional context
+ description: Add any other context about the problem here.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..3ba13e0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1 @@
+blank_issues_enabled: false
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..e9861bc
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,28 @@
+name: Feature request
+description: Suggest an idea for this library
+labels: ["feature-request"]
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for taking the time to fill out this feature request!
+ - type: textarea
+ id: problem
+ attributes:
+ label: Is your feature request related to a problem? Please describe.
+ description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+ - type: textarea
+ id: solution
+ attributes:
+ label: Describe the solution you'd like
+ description: A clear and concise description of what you want to happen.
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Describe alternatives you've considered
+ description: A clear and concise description of any alternative solutions or features you've considered.
+ - type: textarea
+ id: context
+ attributes:
+ label: Additional context
+ description: Add any other context about the feature request here.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..9ae35c7
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+on: [push, pull_request]
+name: CI
+jobs:
+ CI:
+ runs-on: ubuntu-latest
+ env:
+ PHP_INI_VALUES: assert.exception=1, zend.assertions=1
+ strategy:
+ fail-fast: false
+ matrix:
+ php-version:
+ - "7.4"
+ - "8.0"
+ - "8.1"
+ - "8.2"
+ steps:
+ - uses: actions/checkout@master
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+ coverage: xdebug
+ tools: composer:v2, phpcs
+ ini-values: ${{ env.PHP_INI_VALUES }}
+
+ - name: Install dependencies with composer
+ run: composer update --no-ansi --no-interaction --no-progress
+
+ - name: Run linter
+ run: composer lint
+
+ - name: Run tests
+ run: composer test
diff --git a/.gitignore b/.gitignore
index 987e2a2..a25b59a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,37 @@
-composer.lock
-vendor
+# Ignore our deprecation warnings
+.last_api_deprecation_warning
+
+# Ignore Mac OS X fies.
+.DS_Store
+
+# Ignore the /vendor/ directory for people using composer
+/vendor/
+
+# Ignore PHPUnit coverage file
+clover.xml
+
+# Ignore IDE's configuration files
+.idea
+.vscode
+
+# Ignore PHP CS Fixer local config and cache
+.php_cs
+.php_cs.cache
+.php-cs-fixer.cache
+
+# Ignore composer executable
+composer.phar
+
+# Ignore PHPStan local config
+.phpstan.neon
+
+# Ignore phpDocumentor's local config and artifacts
+.phpdoc/*
+phpdoc.xml
+
+# Ignore cached PHPUnit results.
+.phpunit.result.cache
+.phpunit.cache
+
+# Ignore coverage report folder
+.coverage
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..f6990b9
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Changelog
+
+"⚠️" symbol highlights breaking changes.
+
+## 2.0.0 - 2023-02-04
+* [#4](https://github.com/tiendanube/tiendanube-php-sdk/pull/4) API Updates
+ * Relaunched our PHP API to follow current standards.
+ * Support Composer and Packagist
+ * ⚠️ Drop support for PHP 5.6
diff --git a/LICENSE b/LICENSE
index 58161ff..7e07d91 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
The MIT License (MIT)
-Copyright (c) 2013 Tienda Nube
+Copyright (c) 2013 Tiendanube/Nuvemshop
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
diff --git a/README.md b/README.md
index 6d62a55..22e1ff1 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,71 @@
-Tienda Nube/Nuvem Shop SDK for PHP
-==================================
+# Nuvemshop/Tiendanube SDK for PHP
-This SDK provides a simplified access to the [API](https://github.com/TiendaNube/api-docs) of [Nuvem Shop](https://www.nuvemshop.com.br) / [Tienda Nube](https://www.tiendanube.com).
+This SDK provides a simplified access to the [API](https://tiendanube.github.io/api-documentation/) of [Nuvemshop](https://www.nuvemshop.com.br) / [Tiendanube](https://www.tiendanube.com).
-Installation
-------------
-This SDK is mounted on top of [Requests for PHP](https://github.com/rmccue/Requests), so we recommend using [Composer](https://github.com/composer/composer) for installing.
+## Requirements
-Simply add the `tiendanube/php-sdk` requirement to composer.json.
+PHP 7.4.0 and later.
-```json
-{
- "require": {
- "tiendanube/php-sdk": ">=1.0"
- }
-}
-```
+## Composer
+
+You can install the bindings via [Composer](http://getcomposer.org/). Run the following command:
-Then run `composer install` or `composer update` to complete the installation.
+```bash
+composer require stripe/stripe-php
+```
-If you need an autoloader, you can use the one provided by Composer:
+To use the bindings, use Composer's [autoload](https://getcomposer.org/doc/01-basic-usage.md#autoloading):
```php
-require 'vendor/autoload.php';
+require_once('vendor/autoload.php');
```
+## Authenticating Your App
-Authenticating Your App
------------------------
When a user installs your app, he will be taken to your specified Redirect URI with a parameter called `code` containing your temporary authorization code.
With this code you can request a permanent access token.
```php
-$code = $_GET['code'];
-
-$auth = new TiendaNube\Auth(CLIENT_ID, CLIENT_SECRET);
-$store_info = $auth->request_access_token($code);
+use Tiendanube\Context;
+use Tiendanube\Auth\OAuth;
+
+$context = Context::initialize(
+ CLIENT_ID,
+ CLIENT_SECRET,
+ APP_BASE_URL,
+ APP_USER_AGENT_PREFIX,
+);
+
+$oauth = new OAuth();
+$session = $oauth->callback($_GET);
```
-The returned value will contain the id of the authenticated store, as well as the access token and the authorized scopes.
+The returned session will contain the id of the authenticated store, as well as the access token and the authorized scopes.
```php
-var_dump($store_info);
-//array (size=3)
-// 'store_id' => string '1234' (length=4)
-// 'access_token' => string 'a2b544066ee78926bd0dfc8d7bd784e2e016b422' (length=40)
-// 'scope' => string 'read_products,read_orders,read_customers' (length=40)
+var_dump($session);
+//object(Tiendanube\Auth\Session)#5 (3) {
+// ["storeId":"Tiendanube\Auth\Session":private]=>
+// string(4) "1234"
+// ["scope":"Tiendanube\Auth\Session":private]=>
+// string(40) "read_products,read_orders,read_customers"
+// ["accessToken":"Tiendanube\Auth\Session":private]=>
+// string(40) "a2b544066ee78926bd0dfc8d7bd784e2e016b422"
+//}
```
-Keep in mind that future visits to your app will not go through the Redirect URI, so you should store the store id in a session.
+Keep in mind that future visits to your app will not go through the Redirect URI, so you should store the session.
-However, if you need to authenticate a user that has already installed your app (or invite them to install it), you can redirect them to login to the Tienda Nube/Nuvem Shop site.
+However, if you need to authenticate a user that has already installed your app (or invite them to install it), you can redirect them to login to the Nuvemshop/Tiendanube site.
```php
-$auth = new TiendaNube\Auth(CLIENT_ID, CLIENT_SECRET);
+use Tiendanube\Auth\OAuth;
+$auth = new OAuth();
//You can use one of these to obtain a url to login to your app
-$url = $auth->login_url_brazil();
-$url = $auth->login_url_spanish();
+$url = $auth->loginUrlBrazil();
+$url = $auth->loginUrlSpLATAM();
//Redirect to $url
```
@@ -71,92 +78,51 @@ Making a Request
The first step is to instantiate the `API` class with a store id and an access token, as well as a [user agent to identify your app](https://github.com/TiendaNube/api-docs#identify-your-app). Then you can use the `get`, `post`, `put` and `delete` methods.
```php
-$api = new TiendaNube\API(STORE_ID, ACCESS_TOKEN, 'Awesome App (contact@awesome.com)');
-$response = $api->get("products");
-var_dump($response->body);
-```
-You can access the headers of the response via `$response->headers` as if it were an array:
+use Tiendanube\Context;
+use Tiendanube\Auth\Session;
+use Tiendanube\Rest\Adminv1\Product;
-```php
-var_dump(isset($response->headers['X-Total-Count']));
-//boolean true
+$context = Context::initialize(
+ CLIENT_ID,
+ CLIENT_SECRET,
+ 'www.awesome-app.com',
+ 'Awesome App (contact@awesome.com)'
+);
-var_dump($response->headers['X-Total-Count']);
-//string '48' (length=2)
-```
+$session = new Session(
+ STORE_ID,
+ ACCESS_TOKEN,
+ SCOPES
+);
-For convenience, the `X-Main-Language` header can be obtained from `$response->main_language`:
+$productsFromFirstPage = Product::all($session);
+var_dump($productsFromFirstPage);
-```php
-$response = $api->get("products/123456");
-$language = $response->main_language;
-var_dump($response->body->name->$language);
+//You can then access following pages with the same object
+$productsFromSecondPage = Product::all($session, Product::$nextPageQuery);
```
-Other examples:
+You can also call the endpoints directly
```php
-//Create a product
-$response = $api->post("products", [
- 'name' => 'Tienda Nube',
-]);
-$product_id = $response->body->id;
-
-//Change its name
-$response = $api->put("products/$product_id", [
- 'name' => 'Nuvem Shop',
-]);
-
-//And delete it
-$response = $api->delete("products/$product_id");
-
-//You can also send arguments to GET requests
-$response = $api->get("orders", [
- 'since_id' => 10000,
-]);
+use Tiendanube\Context;
+use Tiendanube\Auth\Session;
+
+$context = Context::initialize(
+ CLIENT_ID,
+ CLIENT_SECRET,
+ 'www.awesome-app.com',
+ 'Awesome App (contact@awesome.com)'
+);
+
+$session = new Session(
+ STORE_ID,
+ ACCESS_TOKEN,
+ SCOPES
+);
+
+$client = new \Tiendanube\Clients\Rest($session->getStoreId(), $session->getAccessToken());
+$response = $client->get('products');
+var_dump($response->getStatusCode(), $response->getDecodedBody(), $response->getHeaders(), $response->getPageInfo());
```
-
-For list results you can use the `next`, `prev`, `first` and `last` methods to retrieve the corresponding page as a new response object.
-
-```php
-$response = $api->get('products');
-while($response != null){
- foreach($response->body as $product){
- var_dump($product->id);
- }
- $response = $response->next();
-}
-```
-
-Exceptions
-----------
-Calls to `Auth` may throw a `Tiendanube\Auth\Exception`:
-
-```php
-try{
- $auth->request_access_token($code);
-} catch(Tiendanube\Auth\Exception $e){
- var_dump($e->getMessage());
- //string '[invalid_grant] The authorization code has expired' (length=50)
-}
-```
-
-Likewise, calls to `API` may throw a `Tiendanube\API\Exception`. You can retrieve the original response from these exceptions:
-
-```php
-try{
- $api->get('products');
-} catch(Tiendanube\API\Exception $e){
- var_dump($e->getMessage());
- //string 'Returned with status code 401: Invalid access token' (length=43)
-
- var_dump($e->response->body);
- //object(stdClass)[165]
- // public 'code' => int 401
- // public 'message' => string 'Unauthorized' (length=12)
- // public 'description' => string 'Invalid access token' (length=20)
-}
-```
-
-Requests that return 404 will throw a subclass called `Tiendanube\API\NotFoundException`.
diff --git a/RELEASING.md b/RELEASING.md
new file mode 100644
index 0000000..4c54f2e
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,21 @@
+# Releasing tiendanube-php-sdk
+
+1. Check the Semantic Versioning page for info on how to version the new release: [http://semver.org](http://semver.org)
+
+2. Ensure your local repo is up-to-date
+
+ ```bash
+ git checkout master && git pull
+ ```
+
+3. Add an entry for the new release to `CHANGELOG.md`, and/or move the contents from the _Unreleased_ to the new release
+
+4. Increment the version in `src/Context.php`
+
+5. Stage the `CHANGELOG.md` and `src/Context.php` files
+
+ ```bash
+ git add CHANGELOG.md src/Context.php
+ ```
+
+6. To update the version, commit and push the changes and create the appropriate tag - Packagist will pick it up and release it
diff --git a/composer.json b/composer.json
index 0f9b2cb..b1b8051 100644
--- a/composer.json
+++ b/composer.json
@@ -1,12 +1,46 @@
{
"name": "tiendanube/php-sdk",
- "description": "A PHP SDK for the Tienda Nube/Nuvem Shop API.",
+ "description": "Tiendanube/Nuvemshop PHP library",
"license": "MIT",
+ "keywords": [
+ "tiendanube",
+ "nuvemshop",
+ "commerce",
+ "api"
+ ],
+ "homepage": "https://tiendanube.com/",
+ "authors": [
+ {
+ "name": "Tiendanube/Nuvemshop and contributors",
+ "homepage": "https://github.com/tiendanube/tiendanube-php-sdk/contributors"
+ }
+ ],
"require": {
- "rmccue/requests": ">=1.0"
+ "php": "^7.4 || ^8.0 || ^8.1 || ^8.2",
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "psr/log": "^1.1 || ^2.0 || ^3.0",
+ "psr/http-client": "^1.0",
+ "guzzlehttp/guzzle": "^7.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9",
+ "squizlabs/php_codesniffer": "^3.6",
+ "mikey179/vfsstream": "^1.6"
},
"autoload": {
- "psr-0": {"TiendaNube": "src/"}
+ "psr-4": {
+ "Tiendanube\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tiendanube\\": "tests/"
+ }
+ },
+ "scripts": {
+ "test": "XDEBUG_MODE=coverage ./vendor/bin/phpunit --color",
+ "lint": "./vendor/bin/phpcs --standard=PSR12 src tests"
}
}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..c9e6b99
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,2513 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "c22f9cc644cdfd320ca0cdb3f811bd7b",
+ "packages": [
+ {
+ "name": "guzzlehttp/guzzle",
+ "version": "7.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/guzzle.git",
+ "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b50a2a1251152e43f6a37f0fa053e730a67d25ba",
+ "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "guzzlehttp/promises": "^1.5",
+ "guzzlehttp/psr7": "^1.9 || ^2.4",
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-client": "^1.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "provide": {
+ "psr/http-client-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.1",
+ "ext-curl": "*",
+ "php-http/client-integration-tests": "^3.0",
+ "phpunit/phpunit": "^8.5.29 || ^9.5.23",
+ "psr/log": "^1.1 || ^2.0 || ^3.0"
+ },
+ "suggest": {
+ "ext-curl": "Required for CURL handler support",
+ "ext-intl": "Required for Internationalized Domain Name (IDN) support",
+ "psr/log": "Required for using the Log middleware"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-master": "7.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions_include.php"
+ ],
+ "psr-4": {
+ "GuzzleHttp\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Jeremy Lindblom",
+ "email": "jeremeamia@gmail.com",
+ "homepage": "https://github.com/jeremeamia"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle is a PHP HTTP client library",
+ "keywords": [
+ "client",
+ "curl",
+ "framework",
+ "http",
+ "http client",
+ "psr-18",
+ "psr-7",
+ "rest",
+ "web service"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/guzzle/issues",
+ "source": "https://github.com/guzzle/guzzle/tree/7.5.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-08-28T15:39:27+00:00"
+ },
+ {
+ "name": "guzzlehttp/promises",
+ "version": "1.5.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/promises.git",
+ "reference": "b94b2807d85443f9719887892882d0329d1e2598"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/b94b2807d85443f9719887892882d0329d1e2598",
+ "reference": "b94b2807d85443f9719887892882d0329d1e2598",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.5"
+ },
+ "require-dev": {
+ "symfony/phpunit-bridge": "^4.4 || ^5.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions_include.php"
+ ],
+ "psr-4": {
+ "GuzzleHttp\\Promise\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle promises library",
+ "keywords": [
+ "promise"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/promises/issues",
+ "source": "https://github.com/guzzle/promises/tree/1.5.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-08-28T14:55:35+00:00"
+ },
+ {
+ "name": "guzzlehttp/psr7",
+ "version": "2.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/psr7.git",
+ "reference": "67c26b443f348a51926030c83481b85718457d3d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/67c26b443f348a51926030c83481b85718457d3d",
+ "reference": "67c26b443f348a51926030c83481b85718457d3d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.0",
+ "ralouphie/getallheaders": "^3.0"
+ },
+ "provide": {
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.1",
+ "http-interop/http-factory-tests": "^0.9",
+ "phpunit/phpunit": "^8.5.29 || ^9.5.23"
+ },
+ "suggest": {
+ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-master": "2.4-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
+ }
+ ],
+ "description": "PSR-7 message implementation that also provides common utility methods",
+ "keywords": [
+ "http",
+ "message",
+ "psr-7",
+ "request",
+ "response",
+ "stream",
+ "uri",
+ "url"
+ ],
+ "support": {
+ "issues": "https://github.com/guzzle/psr7/issues",
+ "source": "https://github.com/guzzle/psr7/tree/2.4.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-10-26T14:07:24+00:00"
+ },
+ {
+ "name": "psr/http-client",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
+ "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-client/tree/master"
+ },
+ "time": "2020-06-29T06:28:15+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+ "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0.0",
+ "psr/http-message": "^1.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-factory/tree/master"
+ },
+ "time": "2019-04-30T12:38:16+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
+ "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/http-message/tree/master"
+ },
+ "time": "2016-08-06T14:39:51+00:00"
+ },
+ {
+ "name": "psr/log",
+ "version": "3.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/log.git",
+ "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/log/zipball/fe5ea303b0887d5caefd3d431c3e61ad47037001",
+ "reference": "fe5ea303b0887d5caefd3d431c3e61ad47037001",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.0.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Log\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "https://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for logging libraries",
+ "homepage": "https://github.com/php-fig/log",
+ "keywords": [
+ "log",
+ "psr",
+ "psr-3"
+ ],
+ "support": {
+ "source": "https://github.com/php-fig/log/tree/3.0.0"
+ },
+ "time": "2021-07-14T16:46:02+00:00"
+ },
+ {
+ "name": "ralouphie/getallheaders",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ralouphie/getallheaders.git",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.1",
+ "phpunit/phpunit": "^5 || ^6.5"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/getallheaders.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
+ }
+ ],
+ "description": "A polyfill for getallheaders.",
+ "support": {
+ "issues": "https://github.com/ralouphie/getallheaders/issues",
+ "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+ },
+ "time": "2019-03-08T08:55:37+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v3.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "1ee04c65529dea5d8744774d474e7cbd2f1206d3"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/1ee04c65529dea5d8744774d474e7cbd2f1206d3",
+ "reference": "1ee04c65529dea5d8744774d474e7cbd2f1206d3",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "3.3-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "support": {
+ "source": "https://github.com/symfony/deprecation-contracts/tree/v3.2.0"
+ },
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-11-25T10:21:52+00:00"
+ }
+ ],
+ "packages-dev": [
+ {
+ "name": "doctrine/instantiator",
+ "version": "2.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/instantiator.git",
+ "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0",
+ "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^11",
+ "ext-pdo": "*",
+ "ext-phar": "*",
+ "phpbench/phpbench": "^1.2",
+ "phpstan/phpstan": "^1.9.4",
+ "phpstan/phpstan-phpunit": "^1.3",
+ "phpunit/phpunit": "^9.5.27",
+ "vimeo/psalm": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Marco Pivetta",
+ "email": "ocramius@gmail.com",
+ "homepage": "https://ocramius.github.io/"
+ }
+ ],
+ "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
+ "homepage": "https://www.doctrine-project.org/projects/instantiator.html",
+ "keywords": [
+ "constructor",
+ "instantiate"
+ ],
+ "support": {
+ "issues": "https://github.com/doctrine/instantiator/issues",
+ "source": "https://github.com/doctrine/instantiator/tree/2.0.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.doctrine-project.org/sponsorship.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://www.patreon.com/phpdoctrine",
+ "type": "patreon"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-12-30T00:23:10+00:00"
+ },
+ {
+ "name": "mikey179/vfsstream",
+ "version": "v1.6.11",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/bovigo/vfsStream.git",
+ "reference": "17d16a85e6c26ce1f3e2fa9ceeacdc2855db1e9f"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/bovigo/vfsStream/zipball/17d16a85e6c26ce1f3e2fa9ceeacdc2855db1e9f",
+ "reference": "17d16a85e6c26ce1f3e2fa9ceeacdc2855db1e9f",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.5|^5.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.6.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-0": {
+ "org\\bovigo\\vfs\\": "src/main/php"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Frank Kleine",
+ "homepage": "http://frankkleine.de/",
+ "role": "Developer"
+ }
+ ],
+ "description": "Virtual file system to mock the real file system in unit tests.",
+ "homepage": "http://vfs.bovigo.org/",
+ "support": {
+ "issues": "https://github.com/bovigo/vfsStream/issues",
+ "source": "https://github.com/bovigo/vfsStream/tree/master",
+ "wiki": "https://github.com/bovigo/vfsStream/wiki"
+ },
+ "time": "2022-02-23T02:02:42+00:00"
+ },
+ {
+ "name": "myclabs/deep-copy",
+ "version": "1.11.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/myclabs/DeepCopy.git",
+ "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/14daed4296fae74d9e3201d2c4925d1acb7aa614",
+ "reference": "14daed4296fae74d9e3201d2c4925d1acb7aa614",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "doctrine/collections": "<1.6.8",
+ "doctrine/common": "<2.13.3 || >=3,<3.2.2"
+ },
+ "require-dev": {
+ "doctrine/collections": "^1.6.8",
+ "doctrine/common": "^2.13.3 || ^3.2.2",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/DeepCopy/deep_copy.php"
+ ],
+ "psr-4": {
+ "DeepCopy\\": "src/DeepCopy/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Create deep copies (clones) of your objects",
+ "keywords": [
+ "clone",
+ "copy",
+ "duplicate",
+ "object",
+ "object graph"
+ ],
+ "support": {
+ "issues": "https://github.com/myclabs/DeepCopy/issues",
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.11.0"
+ },
+ "funding": [
+ {
+ "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-03-03T13:19:32+00:00"
+ },
+ {
+ "name": "nikic/php-parser",
+ "version": "v4.15.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/nikic/PHP-Parser.git",
+ "reference": "570e980a201d8ed0236b0a62ddf2c9cbb2034039"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/570e980a201d8ed0236b0a62ddf2c9cbb2034039",
+ "reference": "570e980a201d8ed0236b0a62ddf2c9cbb2034039",
+ "shasum": ""
+ },
+ "require": {
+ "ext-tokenizer": "*",
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "ircmaxell/php-yacc": "^0.0.7",
+ "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0"
+ },
+ "bin": [
+ "bin/php-parse"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.9-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "PhpParser\\": "lib/PhpParser"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Nikita Popov"
+ }
+ ],
+ "description": "A PHP parser written in PHP",
+ "keywords": [
+ "parser",
+ "php"
+ ],
+ "support": {
+ "issues": "https://github.com/nikic/PHP-Parser/issues",
+ "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.3"
+ },
+ "time": "2023-01-16T22:05:37+00:00"
+ },
+ {
+ "name": "phar-io/manifest",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/manifest.git",
+ "reference": "97803eca37d319dfa7826cc2437fc020857acb53"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53",
+ "reference": "97803eca37d319dfa7826cc2437fc020857acb53",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-phar": "*",
+ "ext-xmlwriter": "*",
+ "phar-io/version": "^3.0.1",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0.x-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+ "support": {
+ "issues": "https://github.com/phar-io/manifest/issues",
+ "source": "https://github.com/phar-io/manifest/tree/2.0.3"
+ },
+ "time": "2021-07-20T11:28:43+00:00"
+ },
+ {
+ "name": "phar-io/version",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/phar-io/version.git",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Heuer",
+ "email": "sebastian@phpeople.de",
+ "role": "Developer"
+ },
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "Library for handling version information and constraints",
+ "support": {
+ "issues": "https://github.com/phar-io/version/issues",
+ "source": "https://github.com/phar-io/version/tree/3.2.1"
+ },
+ "time": "2022-02-21T01:04:05+00:00"
+ },
+ {
+ "name": "phpunit/php-code-coverage",
+ "version": "9.2.24",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+ "reference": "2cf940ebc6355a9d430462811b5aaa308b174bed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2cf940ebc6355a9d430462811b5aaa308b174bed",
+ "reference": "2cf940ebc6355a9d430462811b5aaa308b174bed",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-libxml": "*",
+ "ext-xmlwriter": "*",
+ "nikic/php-parser": "^4.14",
+ "php": ">=7.3",
+ "phpunit/php-file-iterator": "^3.0.3",
+ "phpunit/php-text-template": "^2.0.2",
+ "sebastian/code-unit-reverse-lookup": "^2.0.2",
+ "sebastian/complexity": "^2.0",
+ "sebastian/environment": "^5.1.2",
+ "sebastian/lines-of-code": "^1.0.3",
+ "sebastian/version": "^3.0.1",
+ "theseer/tokenizer": "^1.2.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-pcov": "*",
+ "ext-xdebug": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "9.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+ "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+ "keywords": [
+ "coverage",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+ "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.24"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-01-26T08:26:55+00:00"
+ },
+ {
+ "name": "phpunit/php-file-iterator",
+ "version": "3.0.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+ "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+ "keywords": [
+ "filesystem",
+ "iterator"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+ "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/3.0.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2021-12-02T12:48:52+00:00"
+ },
+ {
+ "name": "phpunit/php-invoker",
+ "version": "3.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-invoker.git",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-pcntl": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Invoke callables with a timeout",
+ "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+ "keywords": [
+ "process"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+ "source": "https://github.com/sebastianbergmann/php-invoker/tree/3.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:58:55+00:00"
+ },
+ {
+ "name": "phpunit/php-text-template",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-text-template.git",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Simple template engine.",
+ "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+ "keywords": [
+ "template"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+ "source": "https://github.com/sebastianbergmann/php-text-template/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T05:33:50+00:00"
+ },
+ {
+ "name": "phpunit/php-timer",
+ "version": "5.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/php-timer.git",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Utility class for timing",
+ "homepage": "https://github.com/sebastianbergmann/php-timer/",
+ "keywords": [
+ "timer"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+ "source": "https://github.com/sebastianbergmann/php-timer/tree/5.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:16:10+00:00"
+ },
+ {
+ "name": "phpunit/phpunit",
+ "version": "9.6.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/phpunit.git",
+ "reference": "e7b1615e3e887d6c719121c6d4a44b0ab9645555"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e7b1615e3e887d6c719121c6d4a44b0ab9645555",
+ "reference": "e7b1615e3e887d6c719121c6d4a44b0ab9645555",
+ "shasum": ""
+ },
+ "require": {
+ "doctrine/instantiator": "^1.3.1 || ^2",
+ "ext-dom": "*",
+ "ext-json": "*",
+ "ext-libxml": "*",
+ "ext-mbstring": "*",
+ "ext-xml": "*",
+ "ext-xmlwriter": "*",
+ "myclabs/deep-copy": "^1.10.1",
+ "phar-io/manifest": "^2.0.3",
+ "phar-io/version": "^3.0.2",
+ "php": ">=7.3",
+ "phpunit/php-code-coverage": "^9.2.13",
+ "phpunit/php-file-iterator": "^3.0.5",
+ "phpunit/php-invoker": "^3.1.1",
+ "phpunit/php-text-template": "^2.0.3",
+ "phpunit/php-timer": "^5.0.2",
+ "sebastian/cli-parser": "^1.0.1",
+ "sebastian/code-unit": "^1.0.6",
+ "sebastian/comparator": "^4.0.8",
+ "sebastian/diff": "^4.0.3",
+ "sebastian/environment": "^5.1.3",
+ "sebastian/exporter": "^4.0.5",
+ "sebastian/global-state": "^5.0.1",
+ "sebastian/object-enumerator": "^4.0.3",
+ "sebastian/resource-operations": "^3.0.3",
+ "sebastian/type": "^3.2",
+ "sebastian/version": "^3.0.2"
+ },
+ "suggest": {
+ "ext-soap": "*",
+ "ext-xdebug": "*"
+ },
+ "bin": [
+ "phpunit"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "9.6-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Framework/Assert/Functions.php"
+ ],
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "The PHP Unit Testing framework.",
+ "homepage": "https://phpunit.de/",
+ "keywords": [
+ "phpunit",
+ "testing",
+ "xunit"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.3"
+ },
+ "funding": [
+ {
+ "url": "https://phpunit.de/sponsors.html",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2023-02-04T13:37:15+00:00"
+ },
+ {
+ "name": "sebastian/cli-parser",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/cli-parser.git",
+ "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+ "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for parsing CLI options",
+ "homepage": "https://github.com/sebastianbergmann/cli-parser",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+ "source": "https://github.com/sebastianbergmann/cli-parser/tree/1.0.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T06:08:49+00:00"
+ },
+ {
+ "name": "sebastian/code-unit",
+ "version": "1.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit.git",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/code-unit",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit/tree/1.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:08:54+00:00"
+ },
+ {
+ "name": "sebastian/code-unit-reverse-lookup",
+ "version": "2.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Looks up which function or method a line of code belongs to",
+ "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+ "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/2.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T05:30:19+00:00"
+ },
+ {
+ "name": "sebastian/comparator",
+ "version": "4.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/comparator.git",
+ "reference": "fa0f136dd2334583309d32b62544682ee972b51a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a",
+ "reference": "fa0f136dd2334583309d32b62544682ee972b51a",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/diff": "^4.0",
+ "sebastian/exporter": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@2bepublished.at"
+ }
+ ],
+ "description": "Provides the functionality to compare PHP values for equality",
+ "homepage": "https://github.com/sebastianbergmann/comparator",
+ "keywords": [
+ "comparator",
+ "compare",
+ "equality"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/comparator/issues",
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2022-09-14T12:41:17+00:00"
+ },
+ {
+ "name": "sebastian/complexity",
+ "version": "2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/complexity.git",
+ "reference": "739b35e53379900cc9ac327b2147867b8b6efd88"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88",
+ "reference": "739b35e53379900cc9ac327b2147867b8b6efd88",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.7",
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for calculating the complexity of PHP code units",
+ "homepage": "https://github.com/sebastianbergmann/complexity",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/complexity/issues",
+ "source": "https://github.com/sebastianbergmann/complexity/tree/2.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T15:52:27+00:00"
+ },
+ {
+ "name": "sebastian/diff",
+ "version": "4.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/diff.git",
+ "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/3461e3fccc7cfdfc2720be910d3bd73c69be590d",
+ "reference": "3461e3fccc7cfdfc2720be910d3bd73c69be590d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3",
+ "symfony/process": "^4.2 || ^5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Kore Nordmann",
+ "email": "mail@kore-nordmann.de"
+ }
+ ],
+ "description": "Diff implementation",
+ "homepage": "https://github.com/sebastianbergmann/diff",
+ "keywords": [
+ "diff",
+ "udiff",
+ "unidiff",
+ "unified diff"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/diff/issues",
+ "source": "https://github.com/sebastianbergmann/diff/tree/4.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:10:38+00:00"
+ },
+ {
+ "name": "sebastian/environment",
+ "version": "5.1.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/environment.git",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-posix": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.1-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides functionality to handle HHVM/PHP environments",
+ "homepage": "http://www.github.com/sebastianbergmann/environment",
+ "keywords": [
+ "Xdebug",
+ "environment",
+ "hhvm"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/environment/issues",
+ "source": "https://github.com/sebastianbergmann/environment/tree/5.1.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:03:51+00:00"
+ },
+ {
+ "name": "sebastian/exporter",
+ "version": "4.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/exporter.git",
+ "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
+ "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "ext-mbstring": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Volker Dusch",
+ "email": "github@wallbash.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ },
+ {
+ "name": "Bernhard Schussek",
+ "email": "bschussek@gmail.com"
+ }
+ ],
+ "description": "Provides the functionality to export PHP variables for visualization",
+ "homepage": "https://www.github.com/sebastianbergmann/exporter",
+ "keywords": [
+ "export",
+ "exporter"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/exporter/issues",
+ "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2022-09-14T06:03:37+00:00"
+ },
+ {
+ "name": "sebastian/global-state",
+ "version": "5.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/global-state.git",
+ "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2",
+ "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "ext-dom": "*",
+ "phpunit/phpunit": "^9.3"
+ },
+ "suggest": {
+ "ext-uopz": "*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "5.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Snapshotting of global state",
+ "homepage": "http://www.github.com/sebastianbergmann/global-state",
+ "keywords": [
+ "global state"
+ ],
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/global-state/issues",
+ "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2022-02-14T08:28:10+00:00"
+ },
+ {
+ "name": "sebastian/lines-of-code",
+ "version": "1.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+ "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+ "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc",
+ "shasum": ""
+ },
+ "require": {
+ "nikic/php-parser": "^4.6",
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library for counting the lines of code in PHP source code",
+ "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+ "source": "https://github.com/sebastianbergmann/lines-of-code/tree/1.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-11-28T06:42:11+00:00"
+ },
+ {
+ "name": "sebastian/object-enumerator",
+ "version": "4.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71",
+ "reference": "5c9eeac41b290a3712d88851518825ad78f45c71",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3",
+ "sebastian/object-reflector": "^2.0",
+ "sebastian/recursion-context": "^4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+ "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+ "source": "https://github.com/sebastianbergmann/object-enumerator/tree/4.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:12:34+00:00"
+ },
+ {
+ "name": "sebastian/object-reflector",
+ "version": "2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/object-reflector.git",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "2.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Allows reflection of object attributes, including inherited and non-public ones",
+ "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+ "source": "https://github.com/sebastianbergmann/object-reflector/tree/2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-10-26T13:14:26+00:00"
+ },
+ {
+ "name": "sebastian/recursion-context",
+ "version": "4.0.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/recursion-context.git",
+ "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
+ "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "4.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ },
+ {
+ "name": "Jeff Welch",
+ "email": "whatthejeff@gmail.com"
+ },
+ {
+ "name": "Adam Harvey",
+ "email": "aharvey@php.net"
+ }
+ ],
+ "description": "Provides functionality to recursively process PHP variables",
+ "homepage": "https://github.com/sebastianbergmann/recursion-context",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:07:39+00:00"
+ },
+ {
+ "name": "sebastian/resource-operations",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/resource-operations.git",
+ "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+ "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de"
+ }
+ ],
+ "description": "Provides a list of PHP built-in functions that operate on resources",
+ "homepage": "https://www.github.com/sebastianbergmann/resource-operations",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/resource-operations/issues",
+ "source": "https://github.com/sebastianbergmann/resource-operations/tree/3.0.3"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T06:45:17+00:00"
+ },
+ {
+ "name": "sebastian/type",
+ "version": "3.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/type.git",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.5"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.2-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Collection of value objects that represent the types of the PHP type system",
+ "homepage": "https://github.com/sebastianbergmann/type",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/type/issues",
+ "source": "https://github.com/sebastianbergmann/type/tree/3.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2023-02-03T06:13:03+00:00"
+ },
+ {
+ "name": "sebastian/version",
+ "version": "3.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/sebastianbergmann/version.git",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c",
+ "reference": "c6c1022351a901512170118436c764e473f6de8c",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.3"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.0-dev"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Sebastian Bergmann",
+ "email": "sebastian@phpunit.de",
+ "role": "lead"
+ }
+ ],
+ "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+ "homepage": "https://github.com/sebastianbergmann/version",
+ "support": {
+ "issues": "https://github.com/sebastianbergmann/version/issues",
+ "source": "https://github.com/sebastianbergmann/version/tree/3.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sebastianbergmann",
+ "type": "github"
+ }
+ ],
+ "time": "2020-09-28T06:39:44+00:00"
+ },
+ {
+ "name": "squizlabs/php_codesniffer",
+ "version": "3.7.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
+ "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/1359e176e9307e906dc3d890bcc9603ff6d90619",
+ "reference": "1359e176e9307e906dc3d890bcc9603ff6d90619",
+ "shasum": ""
+ },
+ "require": {
+ "ext-simplexml": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": ">=5.4.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
+ },
+ "bin": [
+ "bin/phpcs",
+ "bin/phpcbf"
+ ],
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Greg Sherwood",
+ "role": "lead"
+ }
+ ],
+ "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
+ "homepage": "https://github.com/squizlabs/PHP_CodeSniffer",
+ "keywords": [
+ "phpcs",
+ "standards"
+ ],
+ "support": {
+ "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues",
+ "source": "https://github.com/squizlabs/PHP_CodeSniffer",
+ "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
+ },
+ "time": "2022-06-18T07:21:10+00:00"
+ },
+ {
+ "name": "theseer/tokenizer",
+ "version": "1.2.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theseer/tokenizer.git",
+ "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e",
+ "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-tokenizer": "*",
+ "ext-xmlwriter": "*",
+ "php": "^7.2 || ^8.0"
+ },
+ "type": "library",
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-3-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Arne Blankerts",
+ "email": "arne@blankerts.de",
+ "role": "Developer"
+ }
+ ],
+ "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+ "support": {
+ "issues": "https://github.com/theseer/tokenizer/issues",
+ "source": "https://github.com/theseer/tokenizer/tree/1.2.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theseer",
+ "type": "github"
+ }
+ ],
+ "time": "2021-07-28T10:34:58+00:00"
+ }
+ ],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": {
+ "php": "^7.4 || ^8.0 || ^8.1",
+ "ext-json": "*",
+ "ext-mbstring": "*"
+ },
+ "platform-dev": [],
+ "plugin-api-version": "2.3.0"
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..96f11aa
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+ tests
+ tests/Rest/Admin*
+
+
+
+
+ src
+
+
+
diff --git a/src/ApiVersion.php b/src/ApiVersion.php
new file mode 100644
index 0000000..a44e16e
--- /dev/null
+++ b/src/ApiVersion.php
@@ -0,0 +1,22 @@
+storeId = $storeId;
+ $this->accessToken = $accessToken;
+ $this->scope = $scope;
+ }
+
+ public function getStoreId(): string
+ {
+ return $this->storeId;
+ }
+ public function getAccessToken(): string
+ {
+ return $this->accessToken;
+ }
+
+ public function getScope(): string
+ {
+ return $this->scope;
+ }
+}
diff --git a/src/Auth/OAuth.php b/src/Auth/OAuth.php
new file mode 100644
index 0000000..7c11eb7
--- /dev/null
+++ b/src/Auth/OAuth.php
@@ -0,0 +1,148 @@
+getStoreId(), $response->getAccessToken(), $response->getScope());
+ }
+
+ /**
+ * Checks whether the given query parameters are from a valid callback request.
+ *
+ * @param array $query The URL query parameters
+ *
+ * @return bool
+ */
+ private static function isCallbackQueryValid(array $query): bool
+ {
+ $state = $query['state'] ?? '';
+ $code = $query['code'] ?? '';
+
+ return (
+ ($code) &&
+ ($state)
+ );
+ }
+
+ /**
+ * * Obtain a permanent access token from an authorization code.
+ * @param string $code Authorization code retrieved from the redirect URI.
+ * @return AccessTokenResponse
+ * @throws HttpRequestException
+ * @throws InvalidOAuthException
+ */
+ private static function fetchAccessToken(string $code)
+ {
+ $post = [
+ 'client_id' => Context::$apiKey,
+ 'client_secret' => Context::$apiSecretKey,
+ 'code' => $code,
+ 'grant_type' => 'authorization_code',
+ ];
+
+ $client = new Http('dummy_store_id');
+ $response = self::requestAccessToken($client, $post);
+ if ($response->getStatusCode() !== 200) {
+ throw new HttpRequestException("Failed to get access token: {$response->getDecodedBody()}");
+ }
+
+ $body = $response->getDecodedBody();
+
+ if (isset($body['error'])) {
+ throw new InvalidOAuthException("[{$body['error']}] {$body['error_description']}");
+ }
+
+ return self::buildAccessTokenResponse($body);
+ }
+
+ /**
+ * Builds an offline access token response object
+ *
+ * @param array $body The HTTP response body
+ */
+ private static function buildAccessTokenResponse(array $body): AccessTokenResponse
+ {
+ return new AccessTokenResponse($body['user_id'], $body['access_token'], $body['scope']);
+ }
+
+ /**
+ * Fires the actual request for the access token. This was isolated so it can be stubbed in unit tests.
+ *
+ * @param Http $client
+ * @param array $post The POST payload
+ *
+ * @return \Tiendanube\Clients\HttpResponse
+ * @throws \Psr\Http\Client\ClientExceptionInterface
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ * @codeCoverageIgnore
+ */
+ public static function requestAccessToken(Http $client, array $post): HttpResponse
+ {
+ return $client->post(self::ACCESS_TOKEN_POST_PATH, $post);
+ }
+}
diff --git a/src/Auth/Scopes.php b/src/Auth/Scopes.php
new file mode 100644
index 0000000..2dad44b
--- /dev/null
+++ b/src/Auth/Scopes.php
@@ -0,0 +1,108 @@
+getImpliedScopes($scopesArray);
+
+ $this->compressedScopes = array_diff($scopesArray, $impliedScopes);
+ $this->expandedScopes = array_merge($scopesArray, $impliedScopes);
+ }
+
+ /**
+ * Converts the scopes in this object to a valid string.
+ *
+ * @return string
+ */
+ public function toString(): string
+ {
+ return implode(self::SCOPE_DELIMITER, $this->toArray());
+ }
+
+ /**
+ * Converts the scopes in this object to a valid array.
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->compressedScopes;
+ }
+
+ /**
+ * Checks whether the scopes in this object encapsulate the given scopes.
+ *
+ * @param string|array|Scopes $scopes The scopes to check
+ *
+ * @return bool
+ */
+ public function has($scopes): bool
+ {
+ if (!($scopes instanceof self)) {
+ $scopes = new self($scopes);
+ }
+
+ return empty(array_diff($scopes->toArray(), $this->expandedScopes));
+ }
+
+ /**
+ * Checks whether the given scopes are equal to the scopes in this object.
+ *
+ * @param string|array|Scopes $scopes The scopes to check
+ *
+ * @return bool
+ */
+ public function equals($scopes): bool
+ {
+ if (!($scopes instanceof self)) {
+ $scopes = new self($scopes);
+ }
+
+ return (
+ count($this->compressedScopes) === count($scopes->compressedScopes) &&
+ empty(array_diff($this->compressedScopes, $scopes->compressedScopes))
+ );
+ }
+
+ /**
+ * Returns any scopes that are implied by any of the given ones.
+ *
+ * @param array $scopes The scopes to check
+ *
+ * @return array
+ */
+ private function getImpliedScopes(array $scopes): array
+ {
+ $impliedScopes = [];
+ foreach ($scopes as $scope) {
+ if (preg_match('/^write_(.*)$/', $scope, $matches)) {
+ $impliedScopes[] = "read_{$matches[1]}";
+ }
+ }
+
+ return $impliedScopes;
+ }
+}
diff --git a/src/Auth/Session.php b/src/Auth/Session.php
new file mode 100644
index 0000000..d84e7e4
--- /dev/null
+++ b/src/Auth/Session.php
@@ -0,0 +1,70 @@
+storeId = $storeId;
+ $this->accessToken = $accessToken;
+ $this->scope = $scope;
+ }
+
+ public function getStoreId(): string
+ {
+ return $this->storeId;
+ }
+
+ /**
+ * @return string
+ */
+ public function getAccessToken()
+ {
+ return $this->accessToken;
+ }
+
+ /**
+ * @return string
+ */
+ public function getScope()
+ {
+ return $this->scope;
+ }
+
+ public function setScope(string $scope): void
+ {
+ $this->scope = $scope;
+ }
+
+ /**
+ * Checks whether this session has all the necessary settings to make requests to Tiendanube/Nuvemshop.
+ *
+ * @return bool
+ */
+ public function isValid(): bool
+ {
+ return (
+ Context::$scopes->equals($this->scope) &&
+ $this->accessToken
+ );
+ }
+}
diff --git a/src/Clients/Http.php b/src/Clients/Http.php
new file mode 100644
index 0000000..bd44841
--- /dev/null
+++ b/src/Clients/Http.php
@@ -0,0 +1,386 @@
+storeId = $storeId;
+ $this->basePath = "/{$apiVersion}/{$storeId}";
+ }
+
+ /**
+ * Makes a GET request to this client's domain.
+ *
+ * @param string $path The URL path to request
+ * @param array $headers Any extra headers to send along with the request
+ * @param array $query Parameters on a query to be added to the URL
+ * @param int|null $tries How many times to attempt the request
+ *
+ * @return HttpResponse
+ * @throws \Psr\Http\Client\ClientExceptionInterface
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ */
+ public function get(string $path, array $headers = [], array $query = [], ?int $tries = null): HttpResponse
+ {
+ return $this->request($path, self::METHOD_GET, null, $headers, $query, $tries);
+ }
+
+ /**
+ * Makes a POST request to this client's domain.
+ *
+ * @param string $path The URL path to request
+ * @param string|array $body The body of the request
+ * @param array $headers Any extra headers to send along with the request
+ * @param array $query Parameters on a query to be added to the URL
+ * @param int|null $tries How many times to attempt the request
+ * @param string $dataType The data type to expect in the response
+ *
+ * @return HttpResponse
+ * @throws \Psr\Http\Client\ClientExceptionInterface
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ */
+ public function post(
+ string $path,
+ $body,
+ array $headers = [],
+ array $query = [],
+ ?int $tries = null,
+ string $dataType = self::DATA_TYPE_JSON
+ ): HttpResponse {
+ return $this->request($path, self::METHOD_POST, $body, $headers, $query, $tries, $dataType);
+ }
+
+ /**
+ * Makes a PUT request to this client's domain.
+ *
+ * @param string $path The URL path to request
+ * @param string|array $body The body of the request
+ * @param array $headers Any extra headers to send along with the request
+ * @param array $query Parameters on a query to be added to the URL
+ * @param int|null $tries How many times to attempt the request
+ * @param string $dataType The data type to expect in the response
+ *
+ * @return HttpResponse
+ * @throws \Psr\Http\Client\ClientExceptionInterface
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ */
+ public function put(
+ string $path,
+ $body,
+ array $headers = [],
+ array $query = [],
+ ?int $tries = null,
+ string $dataType = self::DATA_TYPE_JSON
+ ): HttpResponse {
+ return $this->request($path, self::METHOD_PUT, $body, $headers, $query, $tries, $dataType);
+ }
+
+ /**
+ * Makes a DELETE request to this client's domain.
+ *
+ * @param string $path The URL path to request
+ * @param array $headers Any extra headers to send along with the request
+ * @param array $query Parameters on a query to be added to the URL
+ * @param int|null $tries How many times to attempt the request
+ *
+ * @return HttpResponse
+ * @throws \Psr\Http\Client\ClientExceptionInterface
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ */
+ public function delete(string $path, array $headers = [], array $query = [], ?int $tries = null): HttpResponse
+ {
+ return $this->request(
+ $path,
+ self::METHOD_DELETE,
+ null,
+ $headers,
+ $query,
+ $tries,
+ );
+ }
+
+ /**
+ * Makes a PATCH request to this client's domain.
+ *
+ * @param string $path The URL path to request
+ * @param array $headers Any extra headers to send along with the request
+ * @param array $query Parameters on a query to be added to the URL
+ * @param int|null $tries How many times to attempt the request
+ *
+ * @return HttpResponse
+ * @throws \Psr\Http\Client\ClientExceptionInterface
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ */
+ public function patch(string $path, array $headers = [], array $query = [], ?int $tries = null): HttpResponse
+ {
+ return $this->request(
+ $path,
+ self::METHOD_PATCH,
+ null,
+ $headers,
+ $query,
+ $tries,
+ );
+ }
+
+ /**
+ * Internally handles the logic for making requests.
+ *
+ * @param string $path The path to query
+ * @param string $method The method to use
+ * @param string|array|null $body The request body to send
+ * @param array $headers Any extra headers to send along with the request
+ * @param array $query Parameters on a query to be added to the URL
+ * @param int|null $tries How many times to attempt the request
+ * @param string $dataType The data type of the request
+ *
+ * @return HttpResponse
+ * @throws \Psr\Http\Client\ClientExceptionInterface
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ */
+ protected function request(
+ string $path,
+ string $method,
+ $body = null,
+ array $headers = [],
+ array $query = [],
+ ?int $tries = null,
+ string $dataType = self::DATA_TYPE_JSON
+ ) {
+ $maxTries = $tries ?? 1;
+
+ $version = Context::VERSION;
+ $userAgentParts = ["Tiendanube Admin API Library for PHP v$version"];
+
+ if (Context::$userAgentPrefix) {
+ array_unshift($userAgentParts, Context::$userAgentPrefix);
+ }
+
+ if (isset($headers[HttpHeaders::USER_AGENT])) {
+ array_unshift($userAgentParts, $headers[HttpHeaders::USER_AGENT]);
+ unset($headers[HttpHeaders::USER_AGENT]);
+ }
+
+ if (Context::$enableTelemetry && ! is_null(self::$requestTelemetry)) {
+ $headers[HttpHeaders::X_TIENDANUBE_CLIENT_TELEMETRY] = self::telemetryJson(self::$requestTelemetry);
+ }
+
+ $client = Context::$httpClientFactory->client();
+
+ $query = preg_replace("/%5B[0-9]+%5D/", "%5B%5D", http_build_query($query));
+
+ /*
+ * This is done to check OAuth URI as it uses a different domain.
+ * FALSE means we are hitting the API as usual
+ * TRUE means someone sent a request with a full URI
+ */
+ if (filter_var($path, FILTER_VALIDATE_URL) === false) {
+ $url = (new Uri())
+ ->withHost(self::DOMAIN)
+ ->withPath($this->getRequestPath($path));
+ } else {
+ $url = (new Uri($path));
+ }
+ $url = $url->withScheme('https')
+ ->withQuery($query);
+
+ $request = new Request($method, $url, $headers);
+ $request = $request->withHeader(HttpHeaders::USER_AGENT, implode(' | ', $userAgentParts));
+
+ if ($body) {
+ if (is_string($body)) {
+ $bodyString = $body;
+ } else {
+ $bodyString = json_encode($body);
+ }
+
+ $stream = Utils::streamFor($bodyString);
+ $request = $request
+ ->withBody($stream)
+ ->withHeader(HttpHeaders::CONTENT_TYPE, $dataType)
+ ->withHeader(HttpHeaders::CONTENT_LENGTH, mb_strlen($bodyString));
+ }
+
+ $currentTries = 0;
+ do {
+ $currentTries++;
+
+ $requestStartMs = $this->currentTimeMillis();
+
+ $response = HttpResponse::fromResponse($client->sendRequest($request));
+
+ $responseHeaders = $response->getHeaders();
+
+ if (
+ $response->hasHeader(HttpHeaders::X_REQUEST_ID)
+ && '' !== $response->getHeaderLine(HttpHeaders::X_REQUEST_ID)
+ ) {
+ self::$requestTelemetry = new RequestTelemetry(
+ $responseHeaders[HttpHeaders::X_REQUEST_ID],
+ $this->currentTimeMillis() - $requestStartMs
+ );
+ }
+
+ if (in_array($response->getStatusCode(), self::RETRIABLE_STATUS_CODES)) {
+ $retryAfter = $response->hasHeader(HttpHeaders::RETRY_AFTER)
+ ? $response->getHeaderLine(HttpHeaders::RETRY_AFTER)
+ : Context::$initialNetworkRetryDelay;
+
+ usleep((int)($retryAfter * 1000000));
+ } else {
+ break;
+ }
+ } while ($currentTries < $maxTries);
+
+
+ if ($response->hasHeader(HttpHeaders::X_TIENDANUBE_API_DEPRECATED_REASON)) {
+ $this->logApiDeprecation(
+ $url->__toString(),
+ $response->getHeaderLine(HttpHeaders::X_TIENDANUBE_API_DEPRECATED_REASON)
+ );
+ }
+
+ return $response;
+ }
+
+ protected function getRequestPath(string $path): string
+ {
+ if (strpos($path, '/') !== 0) {
+ $path = "/$path";
+ }
+
+ return $this->basePath . $path;
+ }
+
+ /**
+ * Logs an API deprecation for the given URL to the app's logged, if one was given.
+ *
+ * @param string $url The URL that used a deprecated resource
+ * @param string $reason The deprecation reason
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ */
+ private function logApiDeprecation(string $url, string $reason): void
+ {
+ $warningFilePath = $this->getApiDeprecationTimestampFilePath();
+
+ $lastWarning = null;
+ if (file_exists($warningFilePath)) {
+ $lastWarning = (int)(file_get_contents($warningFilePath));
+ }
+
+ if (time() - $lastWarning < self::DEPRECATION_ALERT_SECONDS) {
+ return;
+ }
+
+ file_put_contents($warningFilePath, time());
+
+ $e = new Exception();
+ $stackTrace = str_replace("\n", "\n ", $e->getTraceAsString());
+
+ // For some reason, code coverage doesn't like the heredoc string, but there's no branching here so if the lines
+ // above are hit, so is this.
+ // @codeCoverageIgnoreStart
+ Context::log(
+ << [
+ 'request_id' => $requestTelemetry->requestId,
+ 'request_duration_ms' => $requestTelemetry->requestDuration,
+ ],
+ ];
+
+ $result = \json_encode($payload);
+ if (false !== $result) {
+ return $result;
+ }
+ Context::log('Serializing telemetry payload failed!', LogLevel::ERROR);
+
+ return '{}';
+ }
+
+ /**
+ * Returns UNIX timestamp in milliseconds.
+ *
+ * @return int current time in millis
+ */
+ private function currentTimeMillis()
+ {
+ return (int) \round(\microtime(true) * 1000);
+ }
+}
diff --git a/src/Clients/HttpClientFactory.php b/src/Clients/HttpClientFactory.php
new file mode 100644
index 0000000..7626c6a
--- /dev/null
+++ b/src/Clients/HttpClientFactory.php
@@ -0,0 +1,19 @@
+ value pairs. Value will be forcibly cast to string so objects that implement
+ * toString are also valid.
+ */
+ public function __construct(array $headers)
+ {
+ $this->normalizeAndLoadHeaders($headers);
+ }
+
+ /**
+ * Checks if this set contains the given header.
+ *
+ * @param string $header The header to check
+ * @param bool $allowEmpty If false, empty headers are handled as missing
+ *
+ * @return bool
+ */
+ public function has(string $header, bool $allowEmpty = true): bool
+ {
+ list($header) = $this->normalizeHeader($header);
+
+ return (
+ array_key_exists($header, $this->headerSet) &&
+ ($allowEmpty || !empty($this->headerSet[$header]))
+ );
+ }
+
+ /**
+ * Returns all of the given headers that are missing in this object.
+ *
+ * @param array $headers The headers to check
+ * @param bool $allowEmpty If false, empty headers are handled as missing
+ *
+ * @return array The headers in $headers that are not set in this object
+ */
+ public function diff(array $headers, bool $allowEmpty = true): array
+ {
+ $missing = [];
+ foreach ($headers as $header) {
+ if (!$this->has($header, $allowEmpty)) {
+ $missing[] = $header;
+ }
+ }
+
+ return $missing;
+ }
+
+ /**
+ * Fetches the given header, returning null if it does not exist.
+ *
+ * @param string $header The header to fetch
+ *
+ * @return string|null
+ */
+ public function get(string $header): ?string
+ {
+ if (!$this->has($header)) {
+ return null;
+ }
+
+ list($header) = $this->normalizeHeader($header);
+ return implode(',', $this->headerSet[$header]);
+ }
+
+ /**
+ * Returns the set of normalized headers as an array of header => value pairs.
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return $this->headerSet;
+ }
+
+ /**
+ * Normalizes and then loads the given set of headers, to make sure we're always working with a consistent object.
+ *
+ * @param array $headers The headers to load
+ */
+ private function normalizeAndLoadHeaders(array $headers): void
+ {
+ $this->headerSet = [];
+ foreach ($headers as $header => $value) {
+ list($header, $value) = $this->normalizeHeader($header, $value);
+ $this->headerSet[$header] = $value;
+ }
+ }
+
+ /**
+ * Normalizes a single header and its value, ensuring that they are both stored in a consistent format.
+ *
+ * @param string $header The header name
+ * @param mixed $value The header's value
+ *
+ * @return array Normalized header in format [$header, $value]
+ */
+ private function normalizeHeader(string $header, $value = null): array
+ {
+ if (empty($value)) {
+ $value = null;
+ } else {
+ $value = array_map(
+ function ($item) {
+ return $item ? (string)$item : null;
+ },
+ (array)$value
+ );
+ }
+
+ return [strtolower($header), $value];
+ }
+}
diff --git a/src/Clients/HttpResponse.php b/src/Clients/HttpResponse.php
new file mode 100644
index 0000000..7ea5d20
--- /dev/null
+++ b/src/Clients/HttpResponse.php
@@ -0,0 +1,58 @@
+getHeaderLine(HttpHeaders::X_REQUEST_ID);
+ $this->requestId = empty($requestIdHeaderValue) ? null : $requestIdHeaderValue;
+ }
+
+ public static function fromResponse(ResponseInterface $response): HttpResponse
+ {
+ return new HttpResponse(
+ $response->getStatusCode(),
+ $response->getHeaders(),
+ $response->getBody(),
+ $response->getProtocolVersion(),
+ $response->getReasonPhrase()
+ );
+ }
+
+ /**
+ * @return array|string|null Body
+ */
+ public function getDecodedBody()
+ {
+ $this->getBody()->rewind();
+ $responseBody = $this->getBody()->getContents();
+ return $responseBody ? json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR) : null;
+ }
+
+ /**
+ * @return string|null request Id
+ */
+ public function getRequestId(): ?string
+ {
+ return $this->requestId;
+ }
+}
diff --git a/src/Clients/PageInfo.php b/src/Clients/PageInfo.php
new file mode 100644
index 0000000..d1ce4de
--- /dev/null
+++ b/src/Clients/PageInfo.php
@@ -0,0 +1,175 @@
+; rel="([^"]+)"/';
+
+ /** @var array|null */
+ private $fields;
+ /** @var string|null */
+ private $previousPageUrl;
+ /** @var string|null */
+ private $nextPageUrl;
+
+ /**
+ * PageInfo constructor.
+ *
+ * @param array|null $fields list of which fields to show in the results.
+ * This parameter only works for some endpoints.
+ * @param string|null $previousPageUrl link to previous page of the result
+ * @param string|null $nextPageUrl link to the next page of the result
+ */
+ public function __construct(
+ ?array $fields,
+ ?string $previousPageUrl,
+ ?string $nextPageUrl
+ ) {
+ $this->fields = $fields;
+ $this->previousPageUrl = $previousPageUrl;
+ $this->nextPageUrl = $nextPageUrl;
+ }
+
+ /**
+ * When you send a request to a REST endpoint that supports cursor-based pagination, the response body returns the
+ * first page of results, and a response header returns links to the next page and the previous page of results
+ * (if applicable). You can use the links in the response header to iterate through the pages of results.
+ *
+ * @param string $linkHeader Pagination link header
+ *
+ * @return \Tiendanube\Clients\PageInfo
+ */
+ public static function fromLinkHeader(string $linkHeader): PageInfo
+ {
+ $linkHeaderSegments = explode(', ', $linkHeader);
+
+ $fields = self::parseFields($linkHeaderSegments);
+
+ list($previousUrl, $nextUrl) = self::parseUrls($linkHeaderSegments);
+
+ return new PageInfo($fields, $previousUrl, $nextUrl);
+ }
+
+ /**
+ * @param array $linkHeaderSegments Link header segments
+ *
+ * @return array
+ */
+ private static function parseFields(array $linkHeaderSegments): array
+ {
+ $fields = [];
+ foreach ($linkHeaderSegments as $segment) {
+ $parsedUrl = [];
+ preg_match(self::LINK_HEADER_REGEXP, $segment, $parsedUrl);
+ $linkUrl = $parsedUrl[1];
+ $queryParams = self::getQueryFromUrl($linkUrl);
+
+ if (array_key_exists('fields', $queryParams)) {
+ $linkFields = $queryParams['fields'];
+ $fields = explode(',', $linkFields);
+ }
+ }
+ return $fields;
+ }
+
+ /**
+ * @param array $linkHeaderSegments Link header segments
+ *
+ * @return array
+ */
+ private static function parseUrls(array $linkHeaderSegments): array
+ {
+ $previousUrl = null;
+ $nextUrl = null;
+ foreach ($linkHeaderSegments as $url) {
+ $parsedLink = [];
+ preg_match(self::LINK_HEADER_REGEXP, $url, $parsedLink);
+ $linkRel = $parsedLink[2];
+ $linkUrl = $parsedLink[1];
+
+ switch ($linkRel) {
+ case 'previous':
+ $previousUrl = $linkUrl;
+ break;
+ case 'next':
+ $nextUrl = $linkUrl;
+ break;
+ }
+ }
+ return array($previousUrl, $nextUrl);
+ }
+
+ /**
+ * @return string|null Url of the previous page or null if there is no more pages
+ */
+ public function getPreviousPageUrl(): ?string
+ {
+ return $this->previousPageUrl;
+ }
+
+ /**
+ * @return string|null Url of the next page or null if there is no more pages
+ */
+ public function getNextPageUrl(): ?string
+ {
+ return $this->nextPageUrl;
+ }
+
+ /**
+ * @return array|null list of which fields to show in the results. This parameter only works for some endpoints.
+ */
+ public function getFields(): ?array
+ {
+ return $this->fields;
+ }
+
+ /**
+ *
+ * $client->get(path: 'products', query: $response->getPageInfo()->getPreviousPageQuery());
+ *
+ *
+ * @return array Query to get the previous page
+ */
+ public function getPreviousPageQuery(): array
+ {
+ return self::getQueryFromUrl($this->getPreviousPageUrl());
+ }
+
+ /**
+ *
+ * $client->get(path: 'products', query: $response->getPageInfo()->getNextPageQuery());
+ *
+ *
+ * @return array Query to get the next page
+ */
+ public function getNextPageQuery(): array
+ {
+ return self::getQueryFromUrl($this->getNextPageUrl());
+ }
+
+ /**
+ * @return bool false if there is no more pages
+ */
+ public function hasNextPage(): bool
+ {
+ return $this->getNextPageUrl() !== null;
+ }
+
+ /**
+ * @return bool false if there is no more pages
+ */
+ public function hasPreviousPage(): bool
+ {
+ return self::getPreviousPageUrl() !== null;
+ }
+
+ private static function getQueryFromUrl(string $url): array
+ {
+ $queryParams = [];
+ parse_str(parse_url($url, PHP_URL_QUERY), $queryParams);
+ return $queryParams;
+ }
+}
diff --git a/src/Clients/RequestTelemetry.php b/src/Clients/RequestTelemetry.php
new file mode 100644
index 0000000..beb2aa8
--- /dev/null
+++ b/src/Clients/RequestTelemetry.php
@@ -0,0 +1,26 @@
+requestId = $requestId;
+ $this->requestDuration = $requestDuration;
+ }
+}
diff --git a/src/Clients/Rest.php b/src/Clients/Rest.php
new file mode 100644
index 0000000..291b60f
--- /dev/null
+++ b/src/Clients/Rest.php
@@ -0,0 +1,72 @@
+accessToken = $accessToken;
+
+ if (!$this->accessToken) {
+ throw new MissingArgumentException('Missing access token when creating REST client');
+ }
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function request(
+ string $path,
+ string $method,
+ $body = null,
+ array $headers = [],
+ array $query = [],
+ ?int $tries = null,
+ string $dataType = self::DATA_TYPE_JSON
+ ): RestResponse {
+ $headers[HttpHeaders::AUTHENTICATION] = "bearer {$this->accessToken}";
+
+ $response = parent::request($path, $method, $body, $headers, $query, $tries, $dataType);
+
+ return new RestResponse(
+ $response->getStatusCode(),
+ $response->getHeaders(),
+ $response->getBody(),
+ $response->getProtocolVersion(),
+ $response->getReasonPhrase(),
+ $this->getPageInfo($response)
+ );
+ }
+
+ /**
+ * @param \Tiendanube\Clients\HttpResponse $response
+ *
+ * @return \Tiendanube\Clients\PageInfo|null
+ */
+ private function getPageInfo(HttpResponse $response): ?PageInfo
+ {
+ $pageInfo = null;
+ if ($response->hasHeader(HttpHeaders::PAGINATION_HEADER)) {
+ $pageInfo = PageInfo::fromLinkHeader($response->getHeaderLine(HttpHeaders::PAGINATION_HEADER));
+ }
+ return $pageInfo;
+ }
+}
diff --git a/src/Clients/RestResponse.php b/src/Clients/RestResponse.php
new file mode 100644
index 0000000..ce69c99
--- /dev/null
+++ b/src/Clients/RestResponse.php
@@ -0,0 +1,34 @@
+pageInfo = $pageInfo;
+ }
+
+ /**
+ * @return \Tiendanube\Clients\PageInfo|null Pagination Information
+ */
+ public function getPageInfo(): ?PageInfo
+ {
+ return $this->pageInfo;
+ }
+}
diff --git a/src/Context.php b/src/Context.php
new file mode 100644
index 0000000..22cba36
--- /dev/null
+++ b/src/Context.php
@@ -0,0 +1,174 @@
+ $apiKey,
+ 'apiSecretKey' => $apiSecretKey,
+ 'hostName' => $hostName,
+ 'userAgentPrefix' => $userAgentPrefix,
+ ];
+ $missing = array();
+ foreach ($requiredValues as $key => $value) {
+ if (!strlen($value)) {
+ $missing[] = $key;
+ }
+ }
+
+ if (!empty($missing)) {
+ $missing = implode(', ', $missing);
+ throw new MissingArgumentException(
+ "Cannot initialize Tiendanube/Nuvemshop API Library. Missing values for: $missing"
+ );
+ }
+
+ if (!\Tiendanube\ApiVersion::isValid($apiVersion)) {
+ throw new InvalidArgumentException("Invalid API version: $apiVersion");
+ }
+
+ if (!preg_match("/http(s)?:\/\//", $hostName)) {
+ $hostName = "https://$hostName";
+ }
+
+ $parsedUrl = parse_url($hostName);
+ if (!is_array($parsedUrl)) {
+ throw new InvalidArgumentException("Invalid host: $hostName");
+ }
+
+ $host = $parsedUrl["host"] . (array_key_exists("port", $parsedUrl) ? ":{$parsedUrl["port"]}" : "");
+
+ self::$apiKey = $apiKey;
+ self::$apiSecretKey = $apiSecretKey;
+ self::$scopes = $authScopes;
+ self::$hostName = $host;
+ self::$hostScheme = $parsedUrl["scheme"];
+ self::$httpClientFactory = new HttpClientFactory();
+ self::$apiVersion = $apiVersion;
+ self::$userAgentPrefix = $userAgentPrefix;
+ self::$logger = $logger;
+
+ self::$isInitialized = true;
+ }
+
+ /**
+ * Throws exception if initialize() has not been called
+ *
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ */
+ public static function throwIfUninitialized(): void
+ {
+ if (!self::$isInitialized) {
+ throw new UninitializedContextException(
+ 'Context has not been properly initialized. ' .
+ 'Please call the .initialize() method to set up your app context object.'
+ );
+ }
+ }
+
+ /**
+ * Logs a message using the defined callback. If none is set, the message is ignored.
+ *
+ * @param string $message The message to log
+ * @param string $level One of the \Psr\Log\LogLevel::* consts, defaults to INFO
+ *
+ * @throws \Tiendanube\Exception\UninitializedContextException
+ */
+ public static function log(string $message, string $level = LogLevel::INFO): void
+ {
+ self::throwIfUninitialized();
+
+ if (!self::$logger) {
+ return;
+ }
+
+ self::$logger->log($level, $message);
+ }
+
+ /**
+ * @param bool $enableTelemetry Enables client telemetry.
+ *
+ * Client telemetry enables timing and request metrics to be sent back to Tiendanube/Nuvemshop as an HTTP Header
+ * with the current request. This enables Tiendanube/Nuvemshop to do latency and metrics analysis
+ * without adding extra overhead (such as extra network calls) on the client.
+ */
+ public static function setEnableTelemetry(bool $enableTelemetry): void
+ {
+ self::$enableTelemetry = $enableTelemetry;
+ }
+}
diff --git a/src/Exception/CookieNotFoundException.php b/src/Exception/CookieNotFoundException.php
new file mode 100644
index 0000000..0059166
--- /dev/null
+++ b/src/Exception/CookieNotFoundException.php
@@ -0,0 +1,11 @@
+statusCode = $statusCode;
+ }
+
+ public function getStatusCode(): int
+ {
+ return $this->statusCode;
+ }
+}
diff --git a/src/Exception/SessionNotFoundException.php b/src/Exception/SessionNotFoundException.php
new file mode 100644
index 0000000..2c157de
--- /dev/null
+++ b/src/Exception/SessionNotFoundException.php
@@ -0,0 +1,11 @@
+ Customer::class
+ ];
+
+ protected static array $paths = [
+ ["http_method" => "get", "operation" => "get", "ids" => [], "path" => "checkouts"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "checkouts/"],
+
+ ["http_method" => "post", "operation" => "coupons", "ids" => ["id"],
+ "path" => "checkouts//coupons"],
+ ];
+
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * since_id,
+ * created_at_max,
+ * updated_at_max,
+ * page,
+ * per_page,
+ * fields,
+ *
+ * @return AbandonedCheckout[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return AbandonedCheckout|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?AbandonedCheckout {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param mixed[] $params
+ * @param array|string $body
+ *
+ * @return array|null
+ */
+ public function coupons(
+ array $params = [],
+ array $body = []
+ ): ?array {
+ $response = parent::request(
+ "post",
+ "coupons",
+ $this->session,
+ ["id" => $this->id],
+ $params,
+ $body,
+ $this,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/Category.php b/src/Rest/Adminv1/Category.php
new file mode 100644
index 0000000..dc7abd7
--- /dev/null
+++ b/src/Rest/Adminv1/Category.php
@@ -0,0 +1,113 @@
+ "get", "operation" => "get", "ids" => [], "path" => "categories"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "categories"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "categories/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "categoeries/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "categoeries/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Category|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Category {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * language,
+ * since_id,
+ * handle,
+ * parent_id,
+ * created_at_max,
+ * created_at_min,
+ * updated_at_min,
+ * updated_at_max,
+ * page,
+ * per_page,
+ * fields,
+ *
+ * @return Category[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/Coupon.php b/src/Rest/Adminv1/Coupon.php
new file mode 100644
index 0000000..ac45672
--- /dev/null
+++ b/src/Rest/Adminv1/Coupon.php
@@ -0,0 +1,124 @@
+ Category::class,
+ ];
+ protected static array $paths = [
+ ["http_method" => "get", "operation" => "get", "ids" => [], "path" => "coupons"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "coupons"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "coupons/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "coupons/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "coupons/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Coupon|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Coupon {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * language,
+ * since_id,
+ * q,
+ * handle,
+ * category_id,
+ * published,
+ * free_shipping,
+ * max_stock,
+ * min_stock,
+ * has_promotional_price,
+ * has_weight,
+ * has_all_dimensions,
+ * has_weight_and_all_dimensions,
+ * created_at_max,
+ * created_at_min,
+ * updated_at_min,
+ * updated_at_max,
+ * sort_by,
+ * page,
+ * per_page,
+ * fields,
+ *
+ * @return Coupon[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/Customer.php b/src/Rest/Adminv1/Customer.php
new file mode 100644
index 0000000..4a043d3
--- /dev/null
+++ b/src/Rest/Adminv1/Customer.php
@@ -0,0 +1,137 @@
+ "get", "operation" => "get", "ids" => [], "path" => "customers"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "customers"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "customers/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "customers/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Customer|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Customer {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * language,
+ * since_id,
+ * q,
+ * handle,
+ * category_id,
+ * published,
+ * free_shipping,
+ * max_stock,
+ * min_stock,
+ * has_promotional_price,
+ * has_weight,
+ * has_all_dimensions,
+ * has_weight_and_all_dimensions,
+ * created_at_max,
+ * created_at_min,
+ * updated_at_min,
+ * updated_at_max,
+ * sort_by,
+ * page,
+ * per_page,
+ * fields,
+ *
+ * @return Customer[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/DraftOrder.php b/src/Rest/Adminv1/DraftOrder.php
new file mode 100644
index 0000000..228f51f
--- /dev/null
+++ b/src/Rest/Adminv1/DraftOrder.php
@@ -0,0 +1,163 @@
+ Customer::class,
+ ];
+ protected static array $hasMany = [];
+ protected static array $paths = [
+ ["http_method" => "get", "operation" => "get", "ids" => [], "path" => "draft_orders"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "draft_orders"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "draft_orders/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "draft_orders/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "draft_orders/"],
+ ];
+
+
+ /**
+ * @param \Tiendanube\Auth\Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return DraftOrder|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?DraftOrder {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * @return DraftOrder[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+}
diff --git a/src/Rest/Adminv1/FulfillmentEvent.php b/src/Rest/Adminv1/FulfillmentEvent.php
new file mode 100644
index 0000000..cba3672
--- /dev/null
+++ b/src/Rest/Adminv1/FulfillmentEvent.php
@@ -0,0 +1,110 @@
+ "get", "operation" => "get", "ids" => ["order_id"],
+ "path" => "orders//fulfillments"],
+
+ ["http_method" => "post", "operation" => "post", "ids" => ["order_id"],
+ "path" => "orders//fulfillments"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["order_id", "id"],
+ "path" => "orders//fulfillments/"],
+
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["order_id", "id"],
+ "path" => "orders//fulfillments/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return FulfillmentEvent|null
+ */
+ public static function find(
+ Session $session,
+ $order_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?FulfillmentEvent {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["order_id" => $order_id, "id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ *
+ * @return FulfillmentEvent[]
+ */
+ public static function all(
+ Session $session,
+ $order_id,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ array_merge(["order_id" => $order_id], $urlIds),
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $order_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["order_id" => $order_id, "id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/FulfillmentOrder.php b/src/Rest/Adminv1/FulfillmentOrder.php
new file mode 100644
index 0000000..f750d0f
--- /dev/null
+++ b/src/Rest/Adminv1/FulfillmentOrder.php
@@ -0,0 +1,115 @@
+ "get", "operation" => "get", "ids" => ["order_id"],
+ "path" => "orders//fulfillment-orders"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["order_id", "id"],
+ "path" => "orders//fulfillment-orders/"],
+
+ ["http_method" => "patch", "operation" => "patch", "ids" => ["order_id", "id"],
+ "path" => "orders//fulfillment-orders/"],
+
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["order_id", "id"],
+ "path" => "orders//fulfillment-orders/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return FulfillmentOrder|null
+ */
+ public static function find(
+ Session $session,
+ $order_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?FulfillmentOrder {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["order_id" => $order_id, "id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ *
+ * @return FulfillmentOrder[]
+ */
+ public static function all(
+ Session $session,
+ $order_id,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ array_merge(["order_id" => $order_id], $urlIds),
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $order_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["order_id" => $order_id, "id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/FulfillmentOrderTrackingEvent.php b/src/Rest/Adminv1/FulfillmentOrderTrackingEvent.php
new file mode 100644
index 0000000..c88fc8d
--- /dev/null
+++ b/src/Rest/Adminv1/FulfillmentOrderTrackingEvent.php
@@ -0,0 +1,128 @@
+ "get", "operation" => "get", "ids" => ["order_id", "fulfillment_order_id"],
+ "path" => "orders//fulfillment-orders//tracking-events"],
+
+ ["http_method" => "post", "operation" => "post", "ids" => ["order_id", "fulfillment_order_id"],
+ "path" => "orders//fulfillment-orders//tracking-events"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["order_id", "fulfillment_order_id", "id"],
+ "path" => "orders//fulfillment-orders//tracking-events/"],
+
+ ["http_method" => "put", "operation" => "put", "ids" => ["order_id", "fulfillment_order_id", "id"],
+ "path" => "orders//fulfillment-orders//tracking-events/"],
+
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["order_id", "fulfillment_order_id", "id"],
+ "path" => "orders//fulfillment-orders//tracking-events/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $order_id
+ * @param int|string $fulfillment_order_id
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return FulfillmentOrderTrackingEvent|null
+ */
+ public static function find(
+ Session $session,
+ $order_id,
+ $fulfillment_order_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?FulfillmentOrderTrackingEvent {
+ $result = parent::baseFind(
+ $session,
+ array_merge([
+ "order_id" => $order_id,
+ "fulfillment_order_id" => $fulfillment_order_id,
+ "id" => $id,
+ ], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $order_id
+ * @param int|string $fulfillment_order_id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ *
+ * @return FulfillmentOrderTrackingEvent[]
+ */
+ public static function all(
+ Session $session,
+ $order_id,
+ $fulfillment_order_id,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ array_merge(["order_id" => $order_id, "fulfillment_order_id" => $fulfillment_order_id], $urlIds),
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $order_id
+ * @param int|string $fulfillment_order_id
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $order_id,
+ $fulfillment_order_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge([
+ "order_id" => $order_id,
+ "fulfillment_order_id" => $fulfillment_order_id,
+ "id" => $id,
+ ], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/Location.php b/src/Rest/Adminv1/Location.php
new file mode 100644
index 0000000..0abe8b4
--- /dev/null
+++ b/src/Rest/Adminv1/Location.php
@@ -0,0 +1,131 @@
+ "get", "operation" => "get", "ids" => [], "path" => "locations"],
+
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "locations"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "locations/"],
+
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "locations/"],
+
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "locations/"],
+
+ ["http_method" => "get", "operation" => "inventory_levels", "ids" => ["id"],
+ "path" => "locations//inventory_levels"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Location|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Location {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * @return Location[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function inventoryLevels(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "get",
+ "inventory_levels",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ [],
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/Metafield.php b/src/Rest/Adminv1/Metafield.php
new file mode 100644
index 0000000..67bce01
--- /dev/null
+++ b/src/Rest/Adminv1/Metafield.php
@@ -0,0 +1,118 @@
+ ProductImage::class,
+ "variants" => ProductVariant::class
+ ];
+ protected static array $paths = [
+ ["http_method" => "get", "operation" => "get", "ids" => ["owner_resource"],
+ "path" => "metafields/"],
+
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "metafields"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"],
+ "path" => "metafields/"],
+
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "metafields/"],
+
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "metafields/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Metafield|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Metafield {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * owner_id,
+ * namespace,
+ * key,
+ * created_at_max,
+ * created_at_min,
+ * updated_at_min,
+ * updated_at_max,
+ * page,
+ * per_page,
+ * fields,
+ *
+ * @return Metafield[]
+ */
+ public static function all(
+ Session $session,
+ string $owner_resource,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ array_merge(["owner_resource" => $owner_resource], $urlIds),
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/Order.php b/src/Rest/Adminv1/Order.php
new file mode 100644
index 0000000..3d5758e
--- /dev/null
+++ b/src/Rest/Adminv1/Order.php
@@ -0,0 +1,257 @@
+ Customer::class,
+ ];
+ protected static array $hasMany = [];
+ protected static array $paths = [
+ ["http_method" => "get", "operation" => "get", "ids" => [], "path" => "orders"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "orders"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "orders/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "orders/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "orders/"],
+ ["http_method" => "post", "operation" => "cancel", "ids" => ["id"], "path" => "orders//cancel"],
+ ["http_method" => "post", "operation" => "close", "ids" => ["id"], "path" => "orders//close"],
+ ["http_method" => "post", "operation" => "open", "ids" => ["id"], "path" => "orders//open"],
+ ];
+
+
+ /**
+ * @param \Tiendanube\Auth\Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Order|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Order {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * since_id,
+ * status,
+ * channels,
+ * payment_status,
+ * shipping_status,
+ * created_at_max,
+ * created_at_min,
+ * updated_at_min,
+ * updated_at_max,
+ * total_max,
+ * total_min,
+ * customer_ids,
+ * app_id,
+ * q,
+ * page,
+ * per_page,
+ * fields,
+ * @return Order[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param mixed[] $params Allowed indexes:
+ * @param array|string $body
+ *
+ * @return array|null
+ */
+ public function cancel(
+ array $params = [],
+ $body = []
+ ): ?array {
+ $response = parent::request(
+ "post",
+ "cancel",
+ $this->session,
+ ["id" => $this->id],
+ $params,
+ $body,
+ $this,
+ );
+
+ return $response->getDecodedBody();
+ }
+
+ /**
+ * @param mixed[] $params
+ * @param array|string $body
+ *
+ * @return array|null
+ */
+ public function close(
+ array $params = [],
+ $body = []
+ ): ?array {
+ $response = parent::request(
+ "post",
+ "close",
+ $this->session,
+ ["id" => $this->id],
+ $params,
+ $body,
+ $this,
+ );
+
+ return $response->getDecodedBody();
+ }
+
+ /**
+ * @param mixed[] $params
+ * @param array|string $body
+ *
+ * @return array|null
+ */
+ public function open(
+ array $params = [],
+ $body = []
+ ): ?array {
+ $response = parent::request(
+ "post",
+ "open",
+ $this->session,
+ ["id" => $this->id],
+ $params,
+ $body,
+ $this,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/PaymentProvider.php b/src/Rest/Adminv1/PaymentProvider.php
new file mode 100644
index 0000000..081f7ae
--- /dev/null
+++ b/src/Rest/Adminv1/PaymentProvider.php
@@ -0,0 +1,129 @@
+ "get", "operation" => "get", "ids" => [], "path" => "payment_providers"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "payment_providers"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "payment_providers/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "payment_providers/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "payment_providers/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return PaymentProvider|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?PaymentProvider {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * language,
+ * since_id,
+ * q,
+ * handle,
+ * category_id,
+ * published,
+ * free_shipping,
+ * max_stock,
+ * min_stock,
+ * has_promotional_price,
+ * has_weight,
+ * has_all_dimensions,
+ * has_weight_and_all_dimensions,
+ * created_at_max,
+ * created_at_min,
+ * updated_at_min,
+ * updated_at_max,
+ * sort_by,
+ * page,
+ * per_page,
+ * fields,
+ *
+ * @return PaymentProvider[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/Product.php b/src/Rest/Adminv1/Product.php
new file mode 100644
index 0000000..52e9ba3
--- /dev/null
+++ b/src/Rest/Adminv1/Product.php
@@ -0,0 +1,134 @@
+ ProductImage::class,
+ "variants" => ProductVariant::class
+ ];
+ protected static array $paths = [
+ ["http_method" => "get", "operation" => "get", "ids" => [], "path" => "products"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "products"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "products/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "products/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "products/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Product|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Product {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * language,
+ * since_id,
+ * q,
+ * handle,
+ * category_id,
+ * published,
+ * free_shipping,
+ * max_stock,
+ * min_stock,
+ * has_promotional_price,
+ * has_weight,
+ * has_all_dimensions,
+ * has_weight_and_all_dimensions,
+ * created_at_max,
+ * created_at_min,
+ * updated_at_min,
+ * updated_at_max,
+ * sort_by,
+ * page,
+ * per_page,
+ * fields,
+ * @return Product[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/ProductImage.php b/src/Rest/Adminv1/ProductImage.php
new file mode 100644
index 0000000..b167aa4
--- /dev/null
+++ b/src/Rest/Adminv1/ProductImage.php
@@ -0,0 +1,145 @@
+ "get", "operation" => "get", "ids" => ["product_id"],
+ "path" => "products//images"],
+
+ ["http_method" => "post", "operation" => "post", "ids" => ["product_id"],
+ "path" => "products//images"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["product_id", "id"],
+ "path" => "products//images/"],
+
+ ["http_method" => "put", "operation" => "put", "ids" => ["product_id", "id"],
+ "path" => "products//images/"],
+
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["product_id", "id"],
+ "path" => "products//images/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds Allowed indexes:
+ * product_id
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return ProductImage|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?ProductImage {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds Allowed indexes:
+ * product_id
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds Allowed indexes:
+ * product_id
+ * @param mixed[] $params Allowed indexes:
+ * since_id,
+ * fields
+ *
+ * @return ProductImage[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ $urlIds,
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds Allowed indexes:
+ * product_id
+ * @param mixed[] $params Allowed indexes:
+ * since_id
+ *
+ * @return array|null
+ */
+ public static function count(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "get",
+ "count",
+ $session,
+ $urlIds,
+ $params,
+ [],
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/ProductVariant.php b/src/Rest/Adminv1/ProductVariant.php
new file mode 100644
index 0000000..cd43d79
--- /dev/null
+++ b/src/Rest/Adminv1/ProductVariant.php
@@ -0,0 +1,168 @@
+ "get", "operation" => "get", "ids" => ["product_id"],
+ "path" => "products//variants"],
+
+ ["http_method" => "post", "operation" => "post", "ids" => ["product_id"],
+ "path" => "products//variants"],
+
+ ["http_method" => "put", "operation" => "put", "ids" => ["product_id"],
+ "path" => "products//variants"],
+
+ ["http_method" => "patch", "operation" => "patch", "ids" => ["product_id"],
+ "path" => "products//variants"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["product_id", "id"],
+ "path" => "products//variants/"],
+
+ ["http_method" => "put", "operation" => "put", "ids" => ["product_id", "id"],
+ "path" => "products//variants/"],
+
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["product_id", "id"],
+ "path" => "products//variants/"],
+
+ ["http_method" => "post", "operation" => "stock", "ids" => ["product_id"],
+ "path" => "products//variants/stock"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return ProductVariant|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?ProductVariant {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds Allowed indexes:
+ * product_id
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $product_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id, "product_id" => $product_id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds Allowed indexes:
+ * product_id
+ * @param mixed[] $params Allowed indexes:
+ * since_id,
+ * created_at_max,
+ * created_at_min,
+ * updated_at_max,
+ * updated_at_min,
+ * page
+ * per_page
+ * fields
+ *
+ * @return ProductVariant[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ $urlIds,
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds Allowed indexes:
+ * product_id
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function stock(
+ Session $session,
+ $product_id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "post",
+ "stock",
+ $session,
+ array_merge(["product_id" => $product_id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/Script.php b/src/Rest/Adminv1/Script.php
new file mode 100644
index 0000000..91bede0
--- /dev/null
+++ b/src/Rest/Adminv1/Script.php
@@ -0,0 +1,116 @@
+ "get", "operation" => "get", "ids" => [], "path" => "scripts"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "scripts"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "scripts/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "scripts/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "scripts/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Script|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Script {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * language,
+ * since_id,
+ * q,
+ * handle,
+ * category_id,
+ * published,
+ * free_shipping,
+ * max_stock,
+ * min_stock,
+ * has_promotional_price,
+ * has_weight,
+ * has_all_dimensions,
+ * has_weight_and_all_dimensions,
+ * created_at_max,
+ * created_at_min,
+ * updated_at_min,
+ * updated_at_max,
+ * sort_by,
+ * page,
+ * per_page,
+ * fields,
+ *
+ * @return Script[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/ShippingCarrier.php b/src/Rest/Adminv1/ShippingCarrier.php
new file mode 100644
index 0000000..a3714f6
--- /dev/null
+++ b/src/Rest/Adminv1/ShippingCarrier.php
@@ -0,0 +1,97 @@
+ "get", "operation" => "get", "ids" => [], "path" => "shipping_carriers"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "shipping_carriers"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "shipping_carriers/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "shipping_carriers/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "shipping_carriers/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return ShippingCarrier|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?ShippingCarrier {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ *
+ * @return ShippingCarrier[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/ShippingCarrierOption.php b/src/Rest/Adminv1/ShippingCarrierOption.php
new file mode 100644
index 0000000..0ea2dc1
--- /dev/null
+++ b/src/Rest/Adminv1/ShippingCarrierOption.php
@@ -0,0 +1,110 @@
+ "get", "operation" => "get", "ids" => ["shipping_carrier_id"],
+ "path" => "shipping_carriers//options"],
+
+ ["http_method" => "post", "operation" => "post", "ids" => ["shipping_carrier_id"],
+ "path" => "shipping_carriers//options"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["shipping_carrier_id", "id"],
+ "path" => "shipping_carriers//options/"],
+
+ ["http_method" => "put", "operation" => "put", "ids" => ["shipping_carrier_id", "id"],
+ "path" => "shipping_carriers//options/"],
+
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["shipping_carrier_id", "id"],
+ "path" => "shipping_carriers//options/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return ShippingCarrierOption|null
+ */
+ public static function find(
+ Session $session,
+ $shipping_carrier_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?ShippingCarrierOption {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["shipping_carrier_id" => $shipping_carrier_id, "id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ *
+ * @return ShippingCarrierOption[]
+ */
+ public static function all(
+ Session $session,
+ $shipping_carrier_id,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ array_merge(["shipping_carrier_id" => $shipping_carrier_id], $urlIds),
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $shipping_carrier_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["shipping_carrier_id" => $shipping_carrier_id, "id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Adminv1/Store.php b/src/Rest/Adminv1/Store.php
new file mode 100644
index 0000000..0409cf3
--- /dev/null
+++ b/src/Rest/Adminv1/Store.php
@@ -0,0 +1,90 @@
+ "get", "operation" => "get", "ids" => [], "path" => "store"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Store|null
+ */
+ public static function get(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): ?Store {
+ $result = parent::baseFind(
+ $session,
+ $urlIds,
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+}
diff --git a/src/Rest/Adminv1/Transaction.php b/src/Rest/Adminv1/Transaction.php
new file mode 100644
index 0000000..b055dab
--- /dev/null
+++ b/src/Rest/Adminv1/Transaction.php
@@ -0,0 +1,85 @@
+ TransactionEvent::class,
+ ];
+ protected static array $paths = [
+ ["http_method" => "get", "operation" => "get", "ids" => ["order_id"],
+ "path" => "orders//transactions"],
+
+ ["http_method" => "post", "operation" => "post", "ids" => ["order_id"],
+ "path" => "orders//transactions"],
+
+ ["http_method" => "get", "operation" => "get", "ids" => ["order_id", "id"],
+ "path" => "orders//transactions/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Product|null
+ */
+ public static function find(
+ Session $session,
+ $order_id,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Transaction {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["order_id" => $order_id, "id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ *
+ * @return Transaction[]
+ */
+ public static function all(
+ Session $session,
+ $order_id,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ array_merge(["order_id" => $order_id], $urlIds),
+ $params,
+ );
+ }
+}
diff --git a/src/Rest/Adminv1/TransactionEvent.php b/src/Rest/Adminv1/TransactionEvent.php
new file mode 100644
index 0000000..1cc9667
--- /dev/null
+++ b/src/Rest/Adminv1/TransactionEvent.php
@@ -0,0 +1,30 @@
+ "post", "operation" => "post", "ids" => ["order_id", "transaction_id"],
+ "path" => "orders//transactions//events"],
+ ];
+}
diff --git a/src/Rest/Adminv1/Webhook.php b/src/Rest/Adminv1/Webhook.php
new file mode 100644
index 0000000..57c2d25
--- /dev/null
+++ b/src/Rest/Adminv1/Webhook.php
@@ -0,0 +1,94 @@
+ "get", "operation" => "get", "ids" => [], "path" => "webhooks"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "webhooks"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "webhooks/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "webhooks/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "webhooks/"],
+ ];
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * fields
+ *
+ * @return Webhook|null
+ */
+ public static function find(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?Webhook {
+ $result = parent::baseFind(
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @param Session $session
+ * @param array $urlIds
+ * @param mixed[] $params Allowed indexes:
+ * @return Webhook[]
+ */
+ public static function all(
+ Session $session,
+ array $urlIds = [],
+ array $params = []
+ ): array {
+ return parent::baseFind(
+ $session,
+ [],
+ $params,
+ );
+ }
+
+ /**
+ * @param Session $session
+ * @param int|string $id
+ * @param array $urlIds
+ * @param mixed[] $params
+ *
+ * @return array|null
+ */
+ public static function delete(
+ Session $session,
+ $id,
+ array $urlIds = [],
+ array $params = []
+ ): ?array {
+ $response = parent::request(
+ "delete",
+ "delete",
+ $session,
+ array_merge(["id" => $id], $urlIds),
+ $params,
+ );
+
+ return $response->getDecodedBody();
+ }
+}
diff --git a/src/Rest/Base.php b/src/Rest/Base.php
new file mode 100644
index 0000000..afb02d6
--- /dev/null
+++ b/src/Rest/Base.php
@@ -0,0 +1,404 @@
+originalState = [];
+ $this->setProps = [];
+ $this->session = $session;
+
+ if (!empty($fromData)) {
+ self::setInstanceData($this, $fromData);
+ }
+ }
+
+ public function save($updateObject = false): void
+ {
+ $data = self::dataDiff($this->toArray(true), $this->originalState);
+
+ $method = !empty($data[static::$primaryKey]) ? "put" : "post";
+
+ $response = self::request($method, $method, $this->session, [], [], $data, $this);
+
+ if ($updateObject) {
+ $body = $response->getDecodedBody();
+
+ self::createInstance($body, $this->session, $this);
+ }
+ }
+
+ public function saveAndUpdate(): void
+ {
+ $this->save(true);
+ }
+
+ public function __get(string $name)
+ {
+ return array_key_exists($name, $this->setProps) ? $this->setProps[$name] : null;
+ }
+
+ public function __set(string $name, $value): void
+ {
+ $this->setProperty($name, $value);
+ }
+
+ public static function getNextPageInfo()
+ {
+ return static::$nextPageQuery;
+ }
+
+ public static function getPreviousPageInfo()
+ {
+ return static::$prevPageQuery;
+ }
+
+ public function toArray($saving = false): array
+ {
+ $data = [];
+
+ foreach ($this->getProperties() as $prop) {
+ if ($saving && in_array($prop, static::$readOnlyAttributes)) {
+ continue;
+ }
+
+ $includeProp = !empty($this->$prop) || array_key_exists($prop, $this->setProps);
+ if (self::isHasManyAttribute($prop)) {
+ if ($includeProp) {
+ $data[$prop] = [];
+ /** @var self $assoc */
+ foreach ($this->$prop as $assoc) {
+ array_push($data[$prop], $this->subAttributeToArray($assoc, $saving));
+ }
+ }
+ } elseif (self::isHasOneAttribute($prop)) {
+ if ($includeProp) {
+ $data[$prop] = $this->subAttributeToArray($this->$prop, $saving);
+ }
+ } elseif ($includeProp) {
+ $data[$prop] = $this->$prop;
+ }
+ }
+
+ return $data;
+ }
+
+ protected static function getJsonBodyName(): string
+ {
+ $className = preg_replace("/^([A-z_0-9]+\\\)*([A-z_]+)/", "$2", static::class);
+ return strtolower(preg_replace("/([a-z])([A-Z])/", "$1_$2", $className));
+ }
+
+ protected static function getJsonResponseBodyName(): string
+ {
+ $className = preg_replace("/^([A-z_0-9]+\\\)*([A-z_]+)/", "$2", static::class);
+ return strtolower(preg_replace("/([a-z])([A-Z])/", "$1_$2", $className));
+ }
+
+ /**
+ * @param string[]|int[] $ids
+ *
+ * @return static[]
+ */
+ protected static function baseFind(Session $session, array $ids = [], array $params = []): array
+ {
+ $response = self::request("get", "get", $session, $ids, $params);
+
+ static::$nextPageQuery = static::$prevPageQuery = null;
+ $pageInfo = $response->getPageInfo();
+ if ($pageInfo) {
+ static::$nextPageQuery = $pageInfo->hasNextPage() ? $pageInfo->getNextPageQuery() : null;
+ static::$prevPageQuery = $pageInfo->hasPreviousPage() ? $pageInfo->getPreviousPageQuery() : null;
+ }
+
+ return static::createInstancesFromResponse($response, $session);
+ }
+
+ /**
+ * @param static $entity
+ */
+ protected static function request(
+ string $httpMethod,
+ string $operation,
+ Session $session,
+ array $ids = [],
+ array $params = [],
+ array $body = [],
+ self $entity = null
+ ): RestResponse {
+ $path = static::getPath($httpMethod, $operation, $ids, $entity);
+
+ $client = new Rest($session->getStoreId(), $session->getAccessToken());
+
+ $params = array_filter($params);
+ switch ($httpMethod) {
+ case "get":
+ $response = $client->get($path, [], $params);
+ break;
+ case "post":
+ $response = $client->post($path, $body, [], $params);
+ break;
+ case "put":
+ $response = $client->put($path, $body, [], $params);
+ break;
+ case "patch":
+ $response = $client->patch($path, [], $params);
+ break;
+ case "delete":
+ $response = $client->delete($path, [], $params);
+ break;
+ }
+
+ $statusCode = $response->getStatusCode();
+ if ($statusCode < 200 || $statusCode >= 300) {
+ $message = "REST request failed";
+
+ $body = $response->getDecodedBody();
+ if (!empty($body["errors"])) {
+ $bodyErrors = json_encode($body["errors"]);
+ $message .= ": {$bodyErrors}";
+ }
+
+ throw new RestResourceRequestException($message, $statusCode);
+ }
+
+ return $response;
+ }
+
+ /**
+ * @param string[]|int[] $ids
+ */
+ private static function getPath(
+ string $httpMethod,
+ string $operation,
+ array $ids,
+ self $entity = null
+ ): ?string {
+ $match = null;
+
+ $maxIds = -1;
+ foreach (static::$paths as $path) {
+ if ($httpMethod !== $path["http_method"] || $operation !== $path["operation"]) {
+ continue;
+ }
+
+ $urlIds = $ids;
+ foreach ($path["ids"] as $id) {
+ if ((!array_key_exists($id, $ids) || $ids[$id] === null) && $entity && $entity->$id) {
+ $urlIds[$id] = $entity->$id;
+ }
+ }
+ $urlIds = array_filter($urlIds);
+
+ if (!empty(array_diff($path["ids"], array_keys($urlIds))) || count($path["ids"]) <= $maxIds) {
+ continue;
+ }
+
+ $maxIds = count($path["ids"]);
+ $match = preg_replace_callback(
+ '/(<([^>]+)>)/',
+ function ($matches) use ($urlIds) {
+ return $urlIds[$matches[2]];
+ },
+ $path["path"]
+ );
+ }
+
+ if (empty($match)) {
+ throw new RestResourceException("Could not find a path for request");
+ }
+
+ if (static::$customPrefix) {
+ $match = preg_replace("/^\/?/", "", static::$customPrefix) . "/$match";
+ }
+ return $match;
+ }
+
+ /**
+ * @return static[]
+ */
+ private static function createInstancesFromResponse(RestResponse $response, Session $session): array
+ {
+ $objects = [];
+
+ $body = $response->getDecodedBody();
+
+ $className = static::getJsonResponseBodyName();
+
+ if (!empty($body)) {
+ if (array_key_exists(0, $body)) {
+ foreach ($body as $entry) {
+ array_push($objects, self::createInstance($entry, $session));
+ }
+ } else {
+ array_push($objects, self::createInstance($body, $session));
+ }
+ }
+
+ return $objects;
+ }
+
+ /**
+ * @return static
+ */
+ private static function createInstance(array $data, Session $session, &$instance = null)
+ {
+ $instance = $instance ?: new static($session);
+
+ if (!empty($data)) {
+ self::setInstanceData($instance, $data);
+ }
+
+ return $instance;
+ }
+
+ private static function isHasManyAttribute(string $property): bool
+ {
+ return array_key_exists($property, static::$hasMany);
+ }
+
+ private static function isHasOneAttribute(string $property): bool
+ {
+ return array_key_exists($property, static::$hasOne);
+ }
+
+ private static function setInstanceData(self &$instance, array $data): void
+ {
+ $instance->originalState = [];
+
+ foreach ($data as $prop => $value) {
+ if (self::isHasManyAttribute($prop)) {
+ $attrList = [];
+ if (!empty($value)) {
+ foreach ($value as $elementData) {
+ array_push(
+ $attrList,
+ static::$hasMany[$prop]::createInstance($elementData, $instance->session)
+ );
+ }
+ }
+
+ $instance->setProperty($prop, $attrList);
+ } elseif (self::isHasOneAttribute($prop)) {
+ if (!empty($value)) {
+ $instance->setProperty(
+ $prop,
+ static::$hasOne[$prop]::createInstance($value, $instance->session)
+ );
+ }
+ } else {
+ $instance->setProperty($prop, $value);
+ $instance->originalState[$prop] = $value;
+ }
+ }
+ }
+
+ private static function dataDiff(array $data1, array $data2): array
+ {
+ $diff = array();
+
+ foreach ($data1 as $key1 => $value1) {
+ if (array_key_exists($key1, $data2)) {
+ if (is_array($value1)) {
+ $recursiveDiff = self::dataDiff($value1, $data2[$key1]);
+ if (count($recursiveDiff)) {
+ $diff[$key1] = $recursiveDiff;
+ }
+ } else {
+ if ($value1 != $data2[$key1]) {
+ $diff[$key1] = $value1;
+ }
+ }
+ } else {
+ $diff[$key1] = $value1;
+ }
+ }
+ return $diff;
+ }
+
+ private function setProperty(string $name, $value): void
+ {
+ $this->$name = $value;
+ $this->setProps[$name] = $value;
+ }
+
+ private function getProperties(): array
+ {
+ $reflection = new ReflectionClass(static::class);
+ $docBlock = $reflection->getDocComment();
+ $lines = explode("\n", (string)$docBlock);
+
+ $props = [];
+ foreach ($lines as $line) {
+ preg_match("/[\s\*]+@property\s+[^\s]+\s+\\$(.*)/", $line, $matches);
+ if (empty($matches)) {
+ continue;
+ }
+
+ $props[] = $matches[1];
+ }
+
+ return array_unique(array_merge($props, array_keys($this->setProps)));
+ }
+
+ /**
+ * @param array|null|Base $attribute
+ * @return array|null
+ */
+ private function subAttributeToArray($attribute, bool $saving)
+ {
+ if (is_array($attribute)) {
+ $subAttribute = static::createInstance($attribute, $this->session);
+ $retVal = $subAttribute->toArray($saving);
+ } elseif (empty($attribute)) {
+ $retVal = $attribute;
+ } else {
+ $retVal = $attribute->toArray($saving);
+ }
+
+ return $retVal;
+ }
+}
diff --git a/src/TiendaNube/API.php b/src/TiendaNube/API.php
deleted file mode 100644
index 169719a..0000000
--- a/src/TiendaNube/API.php
+++ /dev/null
@@ -1,104 +0,0 @@
-access_token = $access_token;
- $this->user_agent = $user_agent;
- $this->requests = new Requests;
-
- $this->url = "https://api.tiendanube.com/{$this->version}/$store_id/";
- }
-
- /**
- * Make a GET request to the specified path.
- *
- * @param string $path The path to the desired resource
- * @param array $params Optional parameters to send in the query string
- * @return TiendaNube/API/Response
- */
- public function get($path, $params = null){
- $url_params = '';
- if (is_array($params)){
- $url_params = '?' . http_build_query($params);
- }
-
- return $this->_call('GET', $path . $url_params);
- }
-
- /**
- * Make a POST request to the specified path.
- *
- * @param string $path The path to the desired resource
- * @param array $params Parameters to send in the POST data
- * @return TiendaNube/API/Response
- */
- public function post($path, $params = []){
- $json = json_encode($params);
-
- return $this->_call('POST', $path, $json);
- }
-
- /**
- * Make a PUT request to the specified path.
- *
- * @param string $path The path to the desired resource
- * @param array $params Parameters to send in the PUT data
- * @return TiendaNube/API/Response
- */
- public function put($path, $params = []){
- $json = json_encode($params);
-
- return $this->_call('PUT', $path, $json);
- }
-
- /**
- * Make a DELETE request to the specified path.
- *
- * @param string $path The path to the desired resource
- * @return TiendaNube/API/Response
- */
- public function delete($path){
- return $this->_call('DELETE', $path);
- }
-
- protected function _call($method, $path, $data = null){
- $headers = [
- 'Authentication' => "bearer {$this->access_token}",
- 'Content-Type' => 'application/json',
- ];
-
- $options = [
- 'timeout' => 10,
- 'useragent' => $this->user_agent,
- ];
-
- $response = $this->requests->request($this->url . $path, $headers, $data, $method, $options);
- $response = new API\Response($this, $response);
- if ($response->status_code == 404){
- throw new API\NotFoundException($response);
- } elseif (!in_array($response->status_code, [200, 201])){
- throw new API\Exception($response);
- }
-
- return $response;
- }
-}
diff --git a/src/TiendaNube/API/Exception.php b/src/TiendaNube/API/Exception.php
deleted file mode 100644
index fdd490c..0000000
--- a/src/TiendaNube/API/Exception.php
+++ /dev/null
@@ -1,23 +0,0 @@
-body;
- if (isset($body->message)){
- $message = isset($body->description) ? $body->description : $body->message;
- } else {
- $message = '';
- foreach ((array) $body as $field => $errors){
- foreach ($errors as $error){
- $message .= "\n[$field] $error";
- }
- }
- }
-
- parent::__construct('Returned with status code ' . $response->status_code . ': ' . $message);
- $this->response = $response;
- }
-}
diff --git a/src/TiendaNube/API/NotFoundException.php b/src/TiendaNube/API/NotFoundException.php
deleted file mode 100644
index 4d7c258..0000000
--- a/src/TiendaNube/API/NotFoundException.php
+++ /dev/null
@@ -1,4 +0,0 @@
-status_code = $response->status_code;
- $this->body = json_decode($response->body);
- $this->headers = $response->headers;
- $this->main_language = isset($response->headers['X-Main-Language']) ? $response->headers['X-Main-Language'] : null;
-
- $this->api = $api;
- }
-
- /**
- * Return the next page of a list response.
- *
- * @return TiendaNube\API\Response
- */
- public function next(){
- return $this->_parse_pagination('next');
- }
-
- /**
- * Return the previous page of a list response.
- *
- * @return TiendaNube\API\Response
- */
- public function prev(){
- return $this->_parse_pagination('prev');
- }
-
- /**
- * Return the first page of a list response.
- *
- * @return TiendaNube\API\Response
- */
- public function first(){
- return $this->_parse_pagination('first');
- }
-
- /**
- * Return the last page of a list response.
- *
- * @return TiendaNube\API\Response
- */
- public function last(){
- return $this->_parse_pagination('last');
- }
-
-
- private function _parse_pagination($key){
- if (isset($this->headers['Link'])){
- $success = preg_match('/<([^>]*)>; rel="'.$key.'"/', $this->headers['Link'], $matches);
- if ($success){
- $url = $matches[1];
- preg_match('|/v\d/\d+/(.*)|', $url, $matches);
-
- return $this->api->get($matches[1]);
- }
- }
-
- return null;
- }
-
-}
diff --git a/src/TiendaNube/Auth.php b/src/TiendaNube/Auth.php
deleted file mode 100644
index 95b76f5..0000000
--- a/src/TiendaNube/Auth.php
+++ /dev/null
@@ -1,74 +0,0 @@
-client_id = $client_id;
- $this->client_secret = $client_secret;
- $this->auth_url = "https://www.tiendanube.com/apps/authorize/token";
- $this->requests = new Requests;
- }
-
- /**
- * Return the url to login to you app in the www.nuvemshop.com.br domain.
- *
- * @return string
- */
- public function login_url_brazil(){
- return "https://www.nuvemshop.com.br/apps/{$this->client_id}/authorize";
- }
-
- /**
- * Return the url to login to you app in the www.tiendanube.com domain.
- *
- * @return string
- */
- public function login_url_spanish(){
- return "https://www.tiendanube.com/apps/{$this->client_id}/authorize";
- }
-
- /**
- * Obtain a permanent access token from an authorization code.
- *
- * @param string $code Authorization code retrieved from the redirect URI.
- */
- public function request_access_token($code){
- $params = [
- 'client_id' => $this->client_id,
- 'client_secret' => $this->client_secret,
- 'code' => $code,
- 'grant_type' => 'authorization_code',
- ];
-
- $response = $this->requests->post($this->auth_url, [], $params);
- if (!$response->success){
- throw new Auth\Exception('Auth url returned with status code ' . $response->status_code);
- }
-
- $body = json_decode($response->body);
- if (isset($body->error)){
- throw new Auth\Exception("[{$body->error}] {$body->error_description}");
- }
-
- return [
- 'store_id' => $body->user_id,
- 'access_token' => $body->access_token,
- 'scope' => $body->scope,
- ];
- }
-}
diff --git a/src/TiendaNube/Auth/Exception.php b/src/TiendaNube/Auth/Exception.php
deleted file mode 100644
index d6b9df3..0000000
--- a/src/TiendaNube/Auth/Exception.php
+++ /dev/null
@@ -1,4 +0,0 @@
-success = $success;
+ $this->errorMessage = $errorMessage;
+ }
+
+ /**
+ * Whether the webhook was processed.
+ *
+ * @return bool
+ */
+ public function isSuccess(): bool
+ {
+ return $this->success;
+ }
+
+ /**
+ * Returns the error message, if the webhook wasn't processed.
+ *
+ * @return string|null
+ */
+ public function getErrorMessage(): ?string
+ {
+ return $this->errorMessage;
+ }
+}
diff --git a/src/Webhooks/RegisterResponse.php b/src/Webhooks/RegisterResponse.php
new file mode 100644
index 0000000..823ff88
--- /dev/null
+++ b/src/Webhooks/RegisterResponse.php
@@ -0,0 +1,36 @@
+success = $success;
+ $this->body = $body;
+ }
+
+ public function isSuccess(): bool
+ {
+ return $this->success;
+ }
+
+ /**
+ * @return string|array|null
+ */
+ public function getBody()
+ {
+ return $this->body;
+ }
+}
diff --git a/src/Webhooks/Registry.php b/src/Webhooks/Registry.php
new file mode 100644
index 0000000..9ddb422
--- /dev/null
+++ b/src/Webhooks/Registry.php
@@ -0,0 +1,252 @@
+get(HttpHeaders::X_TIENDANUBE_HMAC);
+
+ self::validateProcessHmac($rawBody, $hmac);
+
+ $body = json_decode($rawBody, true);
+
+ $store_id = $body['store_id'];
+ $event = $body['event'];
+ $handler = self::getHandler($event);
+ if (!$handler) {
+ throw new MissingWebhookHandlerException("No handler was registered for event '$event'");
+ }
+
+ try {
+ $handler->handle($event, $store_id, $body);
+ $response = new ProcessResponse(true);
+ } catch (Exception $error) {
+ $response = new ProcessResponse(false, $error->getMessage());
+ }
+
+ return $response;
+ }
+
+ /**
+ * Checks if Tiendanube/Nuvemshop already has a callback set for this webhook via API, and checks if we need to
+ * update our subscription if one exists.
+ *
+ * @param \Tiendanube\Auth\Session $session
+ * @param string $event
+ * @param string $callbackAddress
+ *
+ * @return array
+ *
+ * @throws \Tiendanube\Exception\HttpRequestException
+ * @throws \Tiendanube\Exception\MissingArgumentException
+ * @throws \Tiendanube\Exception\WebhookRegistrationException
+ */
+ private static function isWebhookRegistrationNeeded(
+ Session $session,
+ string $event,
+ string $callbackAddress
+ ): array {
+ try {
+ $webhooks = Webhook::all($session, [], [
+ 'url' => $callbackAddress,
+ 'event' => $event,
+ 'page' => 1,
+ 'per_page' => 1,
+ ]);
+ } catch (\Tiendanube\Exception\RestResourceRequestException $e) {
+ throw new WebhookRegistrationException('Failed to check if webhook was already registered');
+ }
+
+ $webhookId = null;
+ $mustRegister = true;
+ if (! empty($webhooks)) {
+ $webhookId = $webhooks[0]->id;
+ $mustRegister = false;
+ }
+
+ return [$webhookId, $mustRegister];
+ }
+
+ /**
+ * Creates or updates a webhook subscription in Tiendanube/Nuvemshop.
+ *
+ * @param \Tiendanube\Auth\Session $session
+ * @param string $event
+ * @param string $callbackAddress
+ * @param string|null $webhookId
+ *
+ * @return array
+ *
+ * @throws \Tiendanube\Exception\HttpRequestException
+ * @throws \Tiendanube\Exception\MissingArgumentException
+ * @throws \Tiendanube\Exception\WebhookRegistrationException
+ */
+ private static function sendRegisterRequest(
+ Session $session,
+ string $event,
+ string $callbackAddress,
+ ?string $webhookId
+ ): array {
+ try {
+ $webhook = new Webhook($session, [
+ 'url' => $callbackAddress,
+ 'event' => $event,
+ ]);
+ $webhook->save(true);
+ } catch (\Tiendanube\Exception\RestResourceRequestException $e) {
+ return [];
+ }
+
+ return $webhook->toArray();
+ }
+
+ /**
+ * Checks if all the necessary headers are given for this to be a valid webhook, returning the parsed headers.
+ *
+ * @param array $rawHeaders The raw HTTP headers from the request
+ *
+ * @return HttpHeaders The parsed headers
+ *
+ * @throws \Tiendanube\Exception\InvalidWebhookException
+ */
+ private static function parseProcessHeaders(array $rawHeaders): HttpHeaders
+ {
+ $headers = new HttpHeaders($rawHeaders);
+
+ $missingHeaders = $headers->diff(
+ [HttpHeaders::X_TIENDANUBE_HMAC],
+ false,
+ );
+
+ if (!empty($missingHeaders)) {
+ $missingHeaders = implode(', ', $missingHeaders);
+ throw new InvalidWebhookException(
+ "Missing one or more of the required HTTP headers to process webhooks: [$missingHeaders]"
+ );
+ }
+
+ return $headers;
+ }
+
+ /**
+ * Checks if the given HMAC hash is valid.
+ *
+ * @param string $rawBody The HTTP request body
+ * @param string $hmac The HMAC from the HTTP headers
+ *
+ * @throws \Tiendanube\Exception\InvalidWebhookException
+ */
+ private static function validateProcessHmac(string $rawBody, string $hmac): void
+ {
+ if ($hmac !== base64_encode(hash_hmac('sha256', $rawBody, Context::$apiSecretKey, true))) {
+ throw new InvalidWebhookException("Could not validate webhook HMAC");
+ }
+ }
+}
diff --git a/tests/APITest.php b/tests/APITest.php
deleted file mode 100644
index e9419cd..0000000
--- a/tests/APITest.php
+++ /dev/null
@@ -1,150 +0,0 @@
-api = new TiendaNube\API(1234, 'abcdefabcdef', 'Test App');
-
- $this->sample_category = '{
- "description": {
- "en": "",
- "es": "",
- "pt": ""
- },
- "handle": {
- "en": "poke-balls",
- "es": "poke-balls",
- "pt": "poke-balls"
- },
- "id": 4567,
- "name": {
- "en": "Poke Balls",
- "es": "Poke Balls",
- "pt": "Poke Balls"
- },
- "parent": null,
- "subcategories": [],
- "created_at": "2013-01-03T09:11:51-03:00",
- "updated_at": "2013-03-11T09:14:11-03:00"
- }';
- }
-
- public function testGet(){
- $mock = new MockRequests($this->sample_category, 200, ['X-Main-Language' => 'es']);
- $this->api->requests = $mock;
-
- $response = $this->api->get('categories/4567');
- $this->assertEquals('https://api.tiendanube.com/v1/1234/categories/4567', $mock->args[0]);
- $this->assertEquals('GET', $mock->args[3]);
-
- $this->assertEquals(200, $response->status_code);
- $this->assertEquals(4567, $response->body->id);
-
- $this->assertTrue(isset($response->headers['X-Main-Language']));
- $this->assertEquals('es', $response->headers['X-Main-Language']);
- }
-
- public function testPost(){
- $mock = new MockRequests($this->sample_category, 201, ['X-Main-Language' => 'es']);
- $this->api->requests = $mock;
-
- $response = $this->api->post('categories', ['name' => 'Poke Balls']);
- $this->assertEquals('https://api.tiendanube.com/v1/1234/categories', $mock->args[0]);
- $this->assertEquals('{"name":"Poke Balls"}', $mock->args[2]);
- $this->assertEquals('POST', $mock->args[3]);
-
- $this->assertEquals(201, $response->status_code);
- $this->assertEquals(4567, $response->body->id);
-
- $this->assertTrue(isset($response->headers['X-Main-Language']));
- $this->assertEquals('es', $response->headers['X-Main-Language']);
- }
-
- public function testPut(){
- $mock = new MockRequests($this->sample_category, 200, ['X-Main-Language' => 'es']);
- $this->api->requests = $mock;
-
- $response = $this->api->put('categories/4567', ['name' => 'Poke Balls']);
- $this->assertEquals('https://api.tiendanube.com/v1/1234/categories/4567', $mock->args[0]);
- $this->assertEquals('{"name":"Poke Balls"}', $mock->args[2]);
- $this->assertEquals('PUT', $mock->args[3]);
-
- $this->assertEquals(200, $response->status_code);
- $this->assertEquals(4567, $response->body->id);
-
- $this->assertTrue(isset($response->headers['X-Main-Language']));
- $this->assertEquals('es', $response->headers['X-Main-Language']);
- }
-
- public function testDelete(){
- $mock = new MockRequests('{}', 200);
- $this->api->requests = $mock;
-
- $response = $this->api->delete('categories/4567');
- $this->assertEquals('https://api.tiendanube.com/v1/1234/categories/4567', $mock->args[0]);
- $this->assertEquals('DELETE', $mock->args[3]);
-
- $this->assertEquals(200, $response->status_code);
- $this->assertEquals(0, count((array) $response->body));
- }
-
- public function testPagination(){
- $mock = new MockRequests('[]', 200, [
- 'X-Main-Language' => 'es',
- 'Link' => '; rel="first", '.
- '; rel="prev", '.
- '; rel="next", '.
- '; rel="last"',
- ]);
-
- $this->api->requests = $mock;
- $response = $this->api->get('products', ['page' => 5]);
- $this->assertEquals('https://api.tiendanube.com/v1/1234/products?page=5', $mock->args[0]);
-
- $response->first();
- $this->assertEquals('https://api.tiendanube.com/v1/1234/products?page=1', $mock->args[0]);
-
- $response->prev();
- $this->assertEquals('https://api.tiendanube.com/v1/1234/products?page=4', $mock->args[0]);
-
- $response->next();
- $this->assertEquals('https://api.tiendanube.com/v1/1234/products?page=6', $mock->args[0]);
-
- $response->last();
- $this->assertEquals('https://api.tiendanube.com/v1/1234/products?page=10', $mock->args[0]);
- }
-
- public function testHeaders(){
- $mock = new MockRequests('[]', 200, ['X-Main-Language' => 'es']);
- $this->api->requests = $mock;
-
- $response = $this->api->get('categories');
-
- $this->assertArrayHasKey('Authentication', $mock->args[1]);
- $this->assertEquals('bearer abcdefabcdef', $mock->args[1]['Authentication']);
-
- $this->assertArrayHasKey('Content-Type', $mock->args[1]);
- $this->assertEquals('application/json', $mock->args[1]['Content-Type']);
-
- $this->assertArrayHasKey('useragent', $mock->args[4]);
- $this->assertEquals('Test App', $mock->args[4]['useragent']);
- }
-
- /**
- * @expectedException TiendaNube\API\Exception
- */
- public function testError(){
- $this->api->requests = new MockRequests('{"code": 401, "message": "Unauthorized", "description": "Invalid access token"}', 401);
- $this->api->get('categories/4567');
- }
-
- /**
- * @expectedException TiendaNube\API\NotFoundException
- */
- public function test404(){
- $this->api->requests = new MockRequests('{"code": 404, "message": "Not Found", "description": "Category with such id does not exist"}', 404);
- $this->api->get('categories/4568');
- }
-}
\ No newline at end of file
diff --git a/tests/Auth/ScopesTest.php b/tests/Auth/ScopesTest.php
new file mode 100644
index 0000000..ad6bfca
--- /dev/null
+++ b/tests/Auth/ScopesTest.php
@@ -0,0 +1,189 @@
+assertEquals('read_products,read_orders,write_customers', $scopes->toString());
+ }
+
+ public function testArrayScopeParsing()
+ {
+ $scopeArray = [' read_products', 'read_orders', 'read_orders', '', 'write_customers '];
+ $scopes = new Scopes($scopeArray);
+
+ $this->assertEquals('read_products,read_orders,write_customers', $scopes->toString());
+ }
+
+ public function testTrimsImpliedScopes()
+ {
+ $scopeString = 'read_customers,write_customers,read_products';
+ $scopes = new Scopes($scopeString);
+
+ $this->assertEquals('write_customers,read_products', $scopes->toString());
+ }
+
+ public function testHasReturnsTrueForStringSubset()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->has('write_customers'));
+ }
+
+ public function testHasReturnsTrueForArraySubset()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->has(['write_customers']));
+ }
+
+ public function testHasReturnsTrueForObjectSubset()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->has(new Scopes('write_customers')));
+ }
+
+ public function testHasReturnsTrueForEqualStrings()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->has('read_products,write_customers'));
+ }
+
+ public function testHasReturnsTrueForEqualArrays()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->has(['read_products', 'write_customers']));
+ }
+
+ public function testHasReturnsTrueForEqualObjects()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->has(new Scopes('read_products,write_customers')));
+ }
+
+ public function testHasReturnsFalseForStringSuperset()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertFalse($scopes->has('read_products,write_customers,read_orders'));
+ }
+
+ public function testHasReturnsFalseForArraySuperset()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertFalse($scopes->has(['read_products', 'write_customers', 'read_orders']));
+ }
+
+ public function testHasReturnsFalseForObjectSuperset()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertFalse($scopes->has(new Scopes('read_products,write_customers,read_orders')));
+ }
+
+ public function testHasReturnsTrueForImpliedObjectScope()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->has(new Scopes('read_products,read_customers')));
+ $this->assertFalse($scopes->has(new Scopes('write_products,write_customers')));
+ }
+
+ public function testHasReturnsTrueForImpliedArrayScope()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->has(['read_products', 'read_customers']));
+ $this->assertFalse($scopes->has(['write_products', 'write_customers']));
+ }
+
+ public function testHasReturnsTrueForImpliedStringScope()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->has('read_products,read_customers'));
+ $this->assertFalse($scopes->has('write_products,write_customers'));
+ }
+
+ public function testEqualsReturnsTrueForEqualScopes()
+ {
+ $scopes1 = new Scopes('write_customers,read_products');
+ $scopes2 = new Scopes(['write_customers', 'read_products']);
+ $this->assertTrue($scopes1->equals($scopes2));
+ $this->assertTrue($scopes2->equals($scopes1));
+ }
+
+ public function testEqualsReturnsFalseForDifferentScopes()
+ {
+ $scopes1 = new Scopes('write_customers,read_products');
+ $scopes2 = new Scopes(['write_customers', 'write_orders']);
+ $this->assertFalse($scopes1->equals($scopes2));
+ $this->assertFalse($scopes2->equals($scopes1));
+ }
+
+ public function testEqualsReturnsTrueForImpliedScopes()
+ {
+ $scopes1 = new Scopes('write_customers,read_products,write_products');
+ $scopes2 = new Scopes(['write_customers', 'write_products']);
+ $this->assertTrue($scopes1->equals($scopes2));
+ $this->assertTrue($scopes2->equals($scopes1));
+ }
+
+ public function testEqualsReturnsFalseForScopeSubsets()
+ {
+ $scopes1 = new Scopes('write_customers,read_products');
+ $scopes2 = new Scopes(['write_customers', 'read_products', 'write_orders']);
+ $this->assertFalse($scopes1->equals($scopes2));
+ $this->assertFalse($scopes2->equals($scopes1));
+ }
+
+ public function testEqualsAllowsStrings()
+ {
+ $scopes1 = new Scopes('write_customers,read_products');
+ $this->assertTrue($scopes1->equals('write_customers,read_products'));
+ $this->assertFalse($scopes1->equals('write_customers,read_products,write_products'));
+ }
+
+ public function testEqualsAllowsArrays()
+ {
+ $scopes1 = new Scopes('write_customers,read_products');
+ $this->assertTrue($scopes1->equals(['write_customers', 'read_products']));
+ $this->assertFalse($scopes1->equals(['write_customers', 'read_products', 'write_products']));
+ }
+
+ public function testEqualsReturnsTrueForImpliedObjectScopes()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+
+ $scopes2 = new Scopes('read_products,read_customers,write_customers');
+ $this->assertTrue($scopes->equals($scopes2));
+ $this->assertTrue($scopes2->equals($scopes));
+
+ $scopes2 = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->equals($scopes2));
+ $this->assertTrue($scopes2->equals($scopes));
+
+ $scopes2 = new Scopes('write_products,read_customers,write_customers');
+ $this->assertFalse($scopes->equals($scopes2));
+ $this->assertFalse($scopes2->equals($scopes));
+ }
+
+ public function testEqualsReturnsTrueForImpliedArrayScopes()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->equals(['read_products', 'read_customers', 'write_customers']));
+ $this->assertTrue($scopes->equals(['read_products', 'write_customers']));
+ $this->assertFalse($scopes->equals(['write_products', 'read_customers', 'write_customers']));
+ }
+
+ public function testEqualsReturnsTrueForImpliedStringScopes()
+ {
+ $scopes = new Scopes('read_products,write_customers');
+ $this->assertTrue($scopes->equals('read_products,read_customers,write_customers'));
+ $this->assertTrue($scopes->equals('read_products,write_customers'));
+ $this->assertFalse($scopes->equals('write_products,read_customers,write_customers'));
+ }
+}
diff --git a/tests/Auth/SessionTest.php b/tests/Auth/SessionTest.php
new file mode 100644
index 0000000..6a2547d
--- /dev/null
+++ b/tests/Auth/SessionTest.php
@@ -0,0 +1,57 @@
+assertEquals('12345', $session->getStoreId());
+ $this->assertEquals('my_access_token', $session->getAccessToken());
+ $this->assertEquals('read_products', $session->getScope());
+ }
+
+ public function testIsValidReturnsTrue()
+ {
+ Context::$scopes = new Scopes('read_products');
+
+ $session = new Session('12345', 'my_access_token', 'read_products');
+
+ $this->assertTrue($session->isValid());
+ }
+
+ public function testIsValidReturnsFalseIfScopesHaveChanged()
+ {
+ Context::$scopes = new Scopes('read_products,write_orders');
+
+ $session = new Session('12345', 'my_access_token', 'read_products');
+
+ $this->assertFalse($session->isValid());
+ }
+
+ public function testIsValidReturnsFalseIfSessionIsCreatedWithoutScopes()
+ {
+ Context::$scopes = new Scopes('read_products,write_orders');
+
+ $session = new Session('12345', 'my_access_token', '');
+
+ $this->assertFalse($session->isValid());
+ }
+
+ public function testIsValidReturnsTrueIfSessionScopesAreUpdated()
+ {
+ Context::$scopes = new Scopes('read_products,write_orders');
+
+ $session = new Session('12345', 'my_access_token', '');
+ $session->setScope('write_orders,,,read_products,');
+
+ $this->assertTrue($session->isValid());
+ }
+}
diff --git a/tests/AuthTest.php b/tests/AuthTest.php
deleted file mode 100644
index 776bf7d..0000000
--- a/tests/AuthTest.php
+++ /dev/null
@@ -1,62 +0,0 @@
-auth = new TiendaNube\Auth(1, 'qwertyuiop');
- }
-
- public function testRequestAccessTokenSuccess(){
- $mock = new MockRequests('{"access_token":"abcdefabcdef","token_type":"bearer","scope":"write_products,write_customers","user_id":"123"}');
- $this->auth->requests = $mock;
-
- $store_info = $this->auth->request_access_token('abc123');
-
- //Request
- $this->assertEquals('https://www.tiendanube.com/apps/authorize/token', $mock->args[0]);
-
- $this->assertArrayHasKey('client_id', $mock->args[2]);
- $this->assertEquals(1, $mock->args[2]['client_id']);
-
- $this->assertArrayHasKey('client_secret', $mock->args[2]);
- $this->assertEquals('qwertyuiop', $mock->args[2]['client_secret']);
-
- $this->assertArrayHasKey('client_id', $mock->args[2]);
- $this->assertEquals('abc123', $mock->args[2]['code']);
-
- $this->assertArrayHasKey('grant_type', $mock->args[2]);
- $this->assertEquals('authorization_code', $mock->args[2]['grant_type']);
-
- //Response
- $this->assertArrayHasKey('store_id', $store_info);
- $this->assertEquals(123, $store_info['store_id']);
-
- $this->assertArrayHasKey('access_token', $store_info);
- $this->assertEquals('abcdefabcdef', $store_info['access_token']);
-
- $this->assertArrayHasKey('scope', $store_info);
- $this->assertEquals('write_products,write_customers', $store_info['scope']);
- }
-
- public function testLoginUrls(){
- $this->assertEquals('https://www.nuvemshop.com.br/apps/1/authorize', $this->auth->login_url_brazil());
- $this->assertEquals('https://www.tiendanube.com/apps/1/authorize', $this->auth->login_url_spanish());
- }
-
- /**
- * @expectedException TiendaNube\Auth\Exception
- */
- public function testRequestAccessTokenExpired(){
- $this->auth->requests = new MockRequests('{"error":"invalid_grant","error_description":"The authorization code has expired"}');
- $this->auth->request_access_token('abc123');
- }
-
- /**
- * @expectedException TiendaNube\Auth\Exception
- */
- public function testRequestAccessTokenError(){
- $this->auth->requests = new MockRequests('{}', 500);
- $this->auth->request_access_token('abc123');
- }
-}
\ No newline at end of file
diff --git a/tests/BaseTestCase.php b/tests/BaseTestCase.php
new file mode 100644
index 0000000..fcc1a1d
--- /dev/null
+++ b/tests/BaseTestCase.php
@@ -0,0 +1,125 @@
+version = Context::VERSION;
+
+ // Make sure we always mock the transport layer so we don't accidentally make real requests
+ $this->mockTransportRequests([]);
+ }
+
+ /**
+ * Builds a mock HTTP response that can optionally also validate the parameters of the cURL call.
+ *
+ * @param int|null $statusCode The HTTP status code to return
+ * @param string|array|null $body The body of the HTTP response
+ * @param array $headers The headers expected in the response
+ * @param string|null $error The cURL error message to return
+ *
+ * @return array
+ */
+ protected function buildMockHttpResponse(
+ int $statusCode = null,
+ $body = null,
+ array $headers = [],
+ string $error = null
+ ): array {
+ if ($body && !is_string($body)) {
+ $body = json_encode($body);
+ }
+
+ return [
+ 'statusCode' => $statusCode,
+ 'body' => $body,
+ 'headers' => $headers,
+ 'error' => $error,
+ ];
+ }
+
+ /**
+ * Sets up a transport layer mock that expects the given requests to happen.
+ *
+ * @param MockRequest[] $requests
+ */
+ public function mockTransportRequests(array $requests): void
+ {
+ $requestMatchers = [];
+ $newResponses = [];
+ foreach ($requests as $request) {
+ $matcher = new HttpRequestMatcher(
+ $request->url,
+ $request->method,
+ "/$request->userAgent/",
+ $request->headers,
+ $request->body ?? "",
+ true,
+ $request->identicalBody
+ );
+
+ $requestMatchers[] = [$matcher];
+
+ $newResponses[] = $request->error ? 'TEST EXCEPTION' : new Response(
+ $request->response['statusCode'],
+ $request->response['headers'],
+ $request->response['body'],
+ );
+ }
+
+ $client = $this->createMock(ClientInterface::class);
+
+ $i = 0;
+ $client->expects($this->exactly(count($requestMatchers)))
+ ->method('sendRequest')
+ ->withConsecutive(...$requestMatchers)
+ ->willReturnCallback(
+ function () use (&$i, $newResponses) {
+ $response = $newResponses[$i++];
+ if ($response === 'TEST EXCEPTION') {
+ throw new HttpRequestException();
+ } else {
+ return $response;
+ }
+ }
+ );
+
+ $factory = $this->createMock(HttpClientFactory::class);
+
+ $factory->expects($this->any())
+ ->method('client')
+ ->willReturn($client);
+
+ Context::$httpClientFactory = $factory;
+ }
+}
diff --git a/tests/Clients/BaseRestResourceTest.php b/tests/Clients/BaseRestResourceTest.php
new file mode 100644
index 0000000..0fd6b70
--- /dev/null
+++ b/tests/Clients/BaseRestResourceTest.php
@@ -0,0 +1,549 @@
+prefix = "https://{$this->domain}/" . Context::$apiVersion . "/{$this->storeId}";
+
+ $this->session = new Session($this->storeId, "dummy-token");
+ }
+
+ public function testFindsResourceById()
+ {
+ $body = ["id" => 1, "attribute" => "attribute"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $body),
+ "{$this->prefix}/fake_resources/1",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $resource = FakeResource::find($this->session, 1);
+ $this->assertEquals([1, "attribute"], [$resource->id, $resource->attribute]);
+ }
+
+ public function testFindsWithParam()
+ {
+ $body = ["id" => 1, "attribute" => "attribute"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $body),
+ "{$this->prefix}/fake_resources/1?param=value",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $resource = FakeResource::find($this->session, 1, ["param" => "value"]);
+ $this->assertEquals([1, "attribute"], [$resource->id, $resource->attribute]);
+ }
+
+ public function testFindsResourceAndChildrenById()
+ {
+ $body = [
+ "id" => 1,
+ "attribute" => "attribute1",
+ "has_one_attribute" => ["id" => 2, "attribute" => "attribute2"],
+ "has_many_attribute" => [
+ ["id" => 3, "attribute" => "attribute3"],
+ ],
+ ];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $body),
+ "{$this->prefix}/fake_resources/1",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $resource = FakeResource::find($this->session, 1);
+ $this->assertEquals([1, "attribute1"], [$resource->id, $resource->attribute]);
+ $this->assertEquals(
+ [2, "attribute2"],
+ [$resource->has_one_attribute->id, $resource->has_one_attribute->attribute]
+ );
+ $this->assertEquals(
+ [3, "attribute3"],
+ [$resource->has_many_attribute[0]->id, $resource->has_many_attribute[0]->attribute]
+ );
+ }
+
+ public function testFindsResourceWithEmptyChildren()
+ {
+ $body = [
+ "id" => 1,
+ "attribute" => "attribute1",
+ "has_one_attribute" => null,
+ "has_many_attribute" => null,
+ ];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $body),
+ "{$this->prefix}/fake_resources/1",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $resource = FakeResource::find($this->session, 1);
+ $this->assertEquals([1, "attribute1"], [$resource->id, $resource->attribute]);
+ $this->assertNull($resource->has_one_attribute);
+ $this->assertEmpty($resource->has_many_attribute);
+ }
+
+ public function testFailsOnFindingNonexistentResourceById()
+ {
+ $body = ["errors" => "Not found"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(404, $body),
+ "{$this->prefix}/fake_resources/1",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $this->expectException(RestResourceRequestException::class);
+ FakeResource::find($this->session, 1);
+ }
+
+ public function testFindsAllResources()
+ {
+ $body = [
+ ["id" => 1, "attribute" => "attribute1"],
+ ["id" => 2, "attribute" => "attribute2"],
+ ];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $body),
+ "{$this->prefix}/fake_resources",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $resources = FakeResource::all($this->session);
+ $this->assertEquals([1, "attribute1"], [$resources[0]->id, $resources[0]->attribute]);
+ $this->assertEquals([2, "attribute2"], [$resources[1]->id, $resources[1]->attribute]);
+ }
+
+ public function testSaves()
+ {
+ $requestBody = ["attribute" => "attribute"];
+ $responseBody = ["id" => 1, "attribute" => "attribute"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $responseBody),
+ "{$this->prefix}/fake_resources",
+ "POST",
+ null,
+ ["Authentication: bearer dummy-token"],
+ json_encode($requestBody)
+ ),
+ ]);
+
+ $resource = new FakeResource($this->session);
+ $resource->attribute = "attribute";
+
+ $resource->save();
+ $this->assertNull($resource->id);
+ }
+
+ public function testSavesAndUpdates()
+ {
+ $requestBody = ["attribute" => "attribute"];
+ $responseBody = ["id" => 1, "attribute" => "attribute"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $responseBody),
+ "{$this->prefix}/fake_resources",
+ "POST",
+ null,
+ ["Authentication: bearer dummy-token"],
+ json_encode($requestBody)
+ ),
+ ]);
+
+ $resource = new FakeResource($this->session);
+ $resource->attribute = "attribute";
+
+ $resource->saveAndUpdate();
+ $this->assertEquals(1, $resource->id);
+ }
+
+ public function testSavesExistingResource()
+ {
+ $requestBody = ["id" => 1, "attribute" => "attribute"];
+ $responseBody = ["id" => 1, "attribute" => "attribute"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $responseBody),
+ "{$this->prefix}/fake_resources/1",
+ "PUT",
+ null,
+ ["Authentication: bearer dummy-token"],
+ json_encode($requestBody)
+ ),
+ ]);
+
+ $resource = new FakeResource($this->session);
+ $resource->id = 1;
+ $resource->attribute = "attribute";
+
+ $resource->save();
+ }
+
+ public function testSavesWithChildren()
+ {
+ $requestBody = [
+ "id" => 1,
+ "has_one_attribute" => ["attribute" => "attribute1"],
+ "has_many_attribute" => [["attribute" => "attribute2"],["attribute" => "attribute3"]],
+ ];
+ $responseBody = ["fake_resource" => ["id" => 1, "attribute" => "attribute"]];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $responseBody),
+ "{$this->prefix}/fake_resources/1",
+ "PUT",
+ null,
+ ["Authentication: bearer dummy-token"],
+ json_encode($requestBody)
+ ),
+ ]);
+
+ $child1 = new FakeResource($this->session);
+ $child1->attribute = "attribute1";
+
+ $child2 = new FakeResource($this->session);
+ $child2->attribute = "attribute2";
+
+ $child3 = new FakeResource($this->session);
+ $child3->attribute = "attribute3";
+
+ $resource = new FakeResource($this->session);
+ $resource->id = 1;
+ $resource->has_one_attribute = $child1;
+ $resource->has_many_attribute = [$child2, $child3];
+
+ $resource->save();
+ }
+
+ public function testLoadsUnknownAttribute()
+ {
+ $body = ["attribute" => "value", "unknown" => "some-value"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, json_encode($body)),
+ "{$this->prefix}/fake_resources/1",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $resource = FakeResource::find($this->session, 1);
+
+ $this->assertEquals("value", $resource->attribute);
+ $this->assertEquals("some-value", $resource->{"unknown"});
+ $this->assertEquals("some-value", $resource->toArray()["unknown"]);
+ }
+
+ public function testSavesWithUnknownAttribute()
+ {
+ $body = ["unknown" => "some-value"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, ""),
+ "{$this->prefix}/fake_resources",
+ "POST",
+ null,
+ ["Authentication: bearer dummy-token"],
+ json_encode($body)
+ ),
+ ]);
+
+ $resource = new FakeResource($this->session);
+ $resource->unknown = "some-value";
+
+ $resource->save();
+ }
+
+ public function testSavesWithForcedNullValue()
+ {
+ $body = ["id" => 1, "has_one_attribute" => null];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, ""),
+ "{$this->prefix}/fake_resources/1",
+ "PUT",
+ null,
+ ["Authentication: bearer dummy-token"],
+ json_encode($body)
+ ),
+ ]);
+
+ $resource = new FakeResource($this->session);
+ $resource->id = 1;
+ $resource->has_one_attribute = null;
+
+ $resource->save();
+ }
+
+ public function testIgnoresUnsaveableAttribute()
+ {
+ $requestBody = ["attribute" => "attribute"];
+ $responseBody = ["id" => 1, "attribute" => "attribute"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $responseBody),
+ "{$this->prefix}/fake_resources",
+ "POST",
+ null,
+ ["Authentication: bearer dummy-token"],
+ json_encode($requestBody),
+ null,
+ true,
+ false,
+ true
+ ),
+ ]);
+
+ $resource = new FakeResource($this->session);
+ $resource->attribute = "attribute";
+ $resource->unsaveable_attribute = "unsaveable_attribute";
+
+ $resource->save();
+ $this->assertNull($resource->id);
+ }
+
+ public function toArrayIncludesReadOnlyAttributes()
+ {
+ $resource = new FakeResource($this->session);
+ $resource->attribute = "attribute";
+ $resource->unsaveable_attribute = "unsaveable_attribute";
+
+ $array = $resource->toArray();
+ $this->assertEquals("attribute", $array["attribute"]);
+ $this->assertEquals("unsaveable_attribute", $array["unsaveable_attribute"]);
+ }
+
+ public function toArrayExcludesReadOnlyAttributesWithSavingArgEqualTrue()
+ {
+ $resource = new FakeResource($this->session);
+ $resource->attribute = "attribute";
+ $resource->unsaveable_attribute = "unsaveable_attribute";
+
+ $array = $resource->toArray(true);
+ $this->assertEquals("attribute", $array["attribute"]);
+ $this->assertArrayNotHasKey("unsaveable_attribute", $array);
+ }
+
+ public function testDeletesExistingResource()
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, ""),
+ "{$this->prefix}/fake_resources/1",
+ "DELETE",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ FakeResource::delete($this->session, 1);
+ }
+
+ public function testDeletesOtherResource()
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, ""),
+ "{$this->prefix}/other_resources/2/fake_resources/1",
+ "DELETE",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ FakeResource::delete($this->session, 1, array("other_resource_id" => 2));
+ }
+
+ public function testFailsDeletingNonexistentResource()
+ {
+ $body = ["errors" => "Not found"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(404, $body),
+ "{$this->prefix}/fake_resources/2",
+ "DELETE",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $this->expectException(RestResourceRequestException::class);
+ FakeResource::delete($this->session, 2);
+ }
+
+ public function testMakesCustomRequests()
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, json_encode(["test body"])),
+ "{$this->prefix}/other_resources/2/fake_resources/1/custom",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $response = FakeResource::custom($this->session, 1, array("other_resource_id" => 2));
+ $this->assertEquals(["test body"], $response);
+ }
+
+ public function testPagination()
+ {
+ $body = [];
+
+ $firstPaginationHeader = $this->getProductsLinkHeader(null, 2);
+ $secondPaginationHeader = $this->getProductsLinkHeader(1, 3);
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(
+ 200,
+ json_encode($body),
+ ["Link" => $firstPaginationHeader]
+ ),
+ "{$this->prefix}/fake_resources",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(
+ 200,
+ json_encode($body),
+ ["Link" => $secondPaginationHeader]
+ ),
+ "{$this->prefix}/fake_resources?per_page=10&fields=test1%2Ctest2&page=2",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(
+ 200,
+ json_encode($body),
+ ["Link" => $firstPaginationHeader]
+ ),
+ "{$this->prefix}/fake_resources?per_page=10&fields=test1%2Ctest2&page=1",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ //We get the first page
+ FakeResource::all($this->session);
+ $this->assertEquals(
+ ["page" => "2", "per_page" => "10", "fields" => "test1,test2"],
+ FakeResource::$nextPageQuery
+ );
+ $this->assertNull(FakeResource::$prevPageQuery);
+
+ //Then we go to the second one
+ FakeResource::all($this->session, FakeResource::$nextPageQuery);
+ $this->assertEquals(
+ ["page" => "3", "per_page" => "10", "fields" => "test1,test2"],
+ FakeResource::$nextPageQuery
+ );
+ $this->assertEquals(
+ ["page" => "1", "per_page" => "10", "fields" => "test1,test2"],
+ FakeResource::$prevPageQuery
+ );
+
+ //And now back to the first one
+ FakeResource::all($this->session, FakeResource::$prevPageQuery);
+ $this->assertEquals(
+ ["page" => "2", "per_page" => "10", "fields" => "test1,test2"],
+ FakeResource::$nextPageQuery
+ );
+ $this->assertNull(FakeResource::$prevPageQuery);
+ }
+
+ public function testAllowsCustomPrefixes()
+ {
+ $body = ["id" => 1, "attribute" => "attribute"];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $body),
+ "{$this->prefix}/custom_prefix/fake_resource_with_custom_prefix/1",
+ "GET",
+ null,
+ ["Authentication: bearer dummy-token"],
+ ),
+ ]);
+
+ $resource = FakeResourceWithCustomPrefix::find($this->session, 1);
+ $this->assertEquals([1, "attribute"], [$resource->id, $resource->attribute]);
+ }
+
+ public function testThrowsOnMismatchedApiVersion()
+ {
+ Context::$apiVersion = "v1000";
+
+ $this->expectException(RestResourceException::class);
+ new FakeResource($this->session);
+ }
+}
diff --git a/tests/Clients/FakeResource.php b/tests/Clients/FakeResource.php
new file mode 100644
index 0000000..cf377a5
--- /dev/null
+++ b/tests/Clients/FakeResource.php
@@ -0,0 +1,73 @@
+ FakeResource::class,
+ ];
+
+ protected static array $hasMany = [
+ "has_many_attribute" => FakeResource::class,
+ ];
+
+ protected static array $readOnlyAttributes = ["unsaveable_attribute"];
+
+ protected static array $paths = [
+ ["http_method" => "get", "operation" => "get", "ids" => [], "path" => "fake_resources"],
+ ["http_method" => "post", "operation" => "post", "ids" => [], "path" => "fake_resources"],
+ ["http_method" => "get", "operation" => "get", "ids" => ["id"], "path" => "fake_resources/"],
+ ["http_method" => "put", "operation" => "put", "ids" => ["id"], "path" => "fake_resources/"],
+ ["http_method" => "delete", "operation" => "delete", "ids" => ["id"], "path" => "fake_resources/"],
+ [
+ "http_method" => "get", "operation" => "custom", "ids" => ["other_resource_id", "id"],
+ "path" => "other_resources//fake_resources//custom",
+ ],
+ [
+ "http_method" => "delete", "operation" => "delete", "ids" => ["other_resource_id", "id"],
+ "path" => "other_resources//fake_resources/",
+ ],
+ ];
+
+ public static function find(Session $session, int $id, array $params = []): ?FakeResource
+ {
+ $result = parent::baseFind($session, ["id" => $id], $params);
+ return !empty($result) ? $result[0] : null;
+ }
+
+ /**
+ * @return FakeResource[]
+ */
+ public static function all(Session $session, array $params = []): array
+ {
+ return parent::baseFind($session, [], $params);
+ }
+
+ public static function delete(Session $session, int $id, array $otherIds = [])
+ {
+ parent::request("delete", "delete", $session, array_merge(["id" => $id], $otherIds));
+ }
+
+ public static function custom(Session $session, int $id, array $otherIds = []): array
+ {
+ return parent::request("get", "custom", $session, array_merge(["id" => $id], $otherIds))->getDecodedBody();
+ }
+}
diff --git a/tests/Clients/FakeResourceWithCustomPrefix.php b/tests/Clients/FakeResourceWithCustomPrefix.php
new file mode 100644
index 0000000..ebc370b
--- /dev/null
+++ b/tests/Clients/FakeResourceWithCustomPrefix.php
@@ -0,0 +1,39 @@
+ "get", "operation" => "get", "ids" => ["id"],
+ "path" => "fake_resource_with_custom_prefix/"
+ ],
+ ];
+
+ protected static ?string $customPrefix = "/custom_prefix";
+
+ public static function find(Session $session, int $id, array $params = []): ?FakeResourceWithCustomPrefix
+ {
+ $result = parent::baseFind($session, ["id" => $id], $params);
+ return !empty($result) ? $result[0] : null;
+ }
+}
diff --git a/tests/Clients/HttpHeadersTest.php b/tests/Clients/HttpHeadersTest.php
new file mode 100644
index 0000000..94bb53b
--- /dev/null
+++ b/tests/Clients/HttpHeadersTest.php
@@ -0,0 +1,127 @@
+ 'application/json',
+ 'X-Custom-Header' => 1234,
+ 'X-Array-Header' => [1234, 4321],
+ ];
+
+ public function testHeadersAreNormalized()
+ {
+ $headers = new HttpHeaders($this->rawHeaders);
+ $this->assertSame(
+ [
+ 'content-type' => ['application/json'],
+ 'x-custom-header' => ['1234'],
+ 'x-array-header' => ['1234', '4321'],
+ ],
+ $headers->toArray(),
+ );
+ }
+
+ public function testHasIsCaseInsensitive()
+ {
+ $headers = new HttpHeaders($this->rawHeaders);
+
+ $this->assertTrue($headers->has('Content-Type'));
+ $this->assertTrue($headers->has('content-type'));
+
+ $this->assertTrue($headers->has('X-Custom-Header'));
+ $this->assertTrue($headers->has('x-custom-header'));
+
+ $this->assertFalse($headers->has('not-there'));
+ }
+
+ public function testHasAcceptsStrings()
+ {
+ $headers = new HttpHeaders($this->rawHeaders);
+
+ $this->assertTrue($headers->has('Content-Type'));
+ $this->assertTrue($headers->has('content-type'));
+
+ $this->assertTrue($headers->has('Content-Type', false));
+ $this->assertTrue($headers->has('content-type', false));
+ }
+
+ public function testHasAcceptsEmptyStrings()
+ {
+ $rawHeaders = ['Test-header' => ''];
+ $headers = new HttpHeaders($rawHeaders);
+
+ $this->assertTrue($headers->has('Test-header'));
+ $this->assertTrue($headers->has('test-header'));
+ }
+
+ public function testHasDoesNotAcceptEmptyStrings()
+ {
+ $rawHeaders = ['Test-header' => ''];
+ $headers = new HttpHeaders($rawHeaders);
+
+ $this->assertFalse($headers->has('Test-header', false));
+ $this->assertFalse($headers->has('test-header', false));
+ }
+
+ public function testDiff()
+ {
+ $headers = new HttpHeaders($this->rawHeaders);
+
+ $this->assertEquals([], $headers->diff(['Content-Type']));
+ $this->assertEquals([], $headers->diff(['content-type']));
+
+ $this->assertEquals(['Not-there'], $headers->diff(['Not-there']));
+ $this->assertEquals(
+ ['Not-there-1', 'Not-there-2'],
+ $headers->diff(['Not-there-1', 'Not-there-2', 'content-type', 'Content-Type'])
+ );
+ }
+
+ public function testDiffAllowsEmptyParam()
+ {
+ $rawHeaders = ['Test-header' => ''];
+ $headers = new HttpHeaders($rawHeaders);
+
+ $this->assertEquals([], $headers->diff(['Test-header']));
+ $this->assertEquals([], $headers->diff(['test-header']));
+
+ $this->assertEquals(['Test-header'], $headers->diff(['Test-header'], false));
+ $this->assertEquals(['test-header'], $headers->diff(['test-header'], false));
+ }
+
+ public function testDiffMultipleValues()
+ {
+ $rawHeaders = ['Test-header' => '', 'Test-header-2' => 'Value'];
+ $headers = new HttpHeaders($rawHeaders);
+
+ $this->assertEquals([], $headers->diff(['Test-header', 'Test-header-2']));
+ $this->assertEquals([], $headers->diff(['test-header', 'test-header-2']));
+
+ $this->assertEquals(['Test-header'], $headers->diff(['Test-header', 'Test-header-2'], false));
+ $this->assertEquals(['test-header'], $headers->diff(['test-header', 'test-header-2'], false));
+ }
+
+ public function testGetIsCaseInsensitiveAndReturnsStrings()
+ {
+ $headers = new HttpHeaders($this->rawHeaders);
+
+ $this->assertEquals('application/json', $headers->get('Content-Type'));
+ $this->assertEquals('application/json', $headers->get('content-type'));
+
+ $this->assertEquals('1234', $headers->get('X-Custom-Header'));
+ $this->assertEquals('1234', $headers->get('x-custom-header'));
+
+ $this->assertEquals('1234,4321', $headers->get('X-Array-Header'));
+ $this->assertEquals('1234,4321', $headers->get('x-array-header'));
+
+ $this->assertNull($headers->get('not-there'));
+ }
+}
diff --git a/tests/Clients/HttpResponseTest.php b/tests/Clients/HttpResponseTest.php
new file mode 100644
index 0000000..8e79da0
--- /dev/null
+++ b/tests/Clients/HttpResponseTest.php
@@ -0,0 +1,46 @@
+ ['ABCD'], 'Header-2' => ['DCBA'], 'x-request-id' => ['test-request-id']],
+ '{"name": "Super store"}',
+ );
+ $this->assertEquals(200, $response->getStatusCode());
+ $this->assertEqualsCanonicalizing(
+ [
+ 'Header-1' => ['ABCD'],
+ 'Header-2' => ['DCBA'],
+ 'x-request-id' => ['test-request-id'],
+ ],
+ $response->getHeaders(),
+ );
+ $this->assertEquals(['name' => 'Super store'], $response->getDecodedBody());
+ $this->assertEquals('test-request-id', $response->getRequestId());
+ }
+
+ public function testGetRequestIdReturnsNullIfHeaderIsMissing()
+ {
+ $response = new HttpResponse(200);
+ $this->assertNull($response->getRequestId());
+ }
+
+ public function testGetDecodedBodyWillThrwoExceptionIfBodyIsNotJson()
+ {
+ $response = new HttpResponse(200, [], "not-json");
+
+ $this->expectException(JsonException::class);
+ $response->getDecodedBody();
+ }
+}
diff --git a/tests/Clients/HttpTest.php b/tests/Clients/HttpTest.php
new file mode 100644
index 0000000..d99c395
--- /dev/null
+++ b/tests/Clients/HttpTest.php
@@ -0,0 +1,503 @@
+ 'Test Product',
+ 'amount' => 1,
+ ];
+ /** @var array */
+ private $successResponse = [
+ 'products' => [
+ 'title' => 'Test Product',
+ 'amount' => 1,
+ ],
+ ];
+
+ public function testGetRequestWithoutQuery()
+ {
+ $headers = ['X-Test-Header' => 'test_value'];
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ ['X-Test-Header: test_value'],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+ $response = $client->get('test/path', $headers);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testGetRequest()
+ {
+ $headers = ['X-Test-Header' => 'test_value'];
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path?path=some_path",
+ "GET",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ ['X-Test-Header: test_value'],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+ $response = $client->get('test/path', $headers, ["path" => "some_path"]);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testGetRequestWithArrayInQuery()
+ {
+ $headers = ['X-Test-Header' => 'test_value'];
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion .
+ "/1/test/path?array[]=value&hash[key1]=value1&hash[key2]=value2",
+ "GET",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ ['X-Test-Header: test_value'],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+ $response = $client->get(
+ 'test/path',
+ $headers,
+ ["array" => ["value"], "hash" => ["key1" => "value1", "key2" => "value2"]]
+ );
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testPostRequest()
+ {
+ $headers = ['X-Test-Header' => 'test_value'];
+
+ $body = json_encode($this->product1);
+ $bodyLength = strlen($body);
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path?path=some_path",
+ "POST",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ [
+ 'Content-Type: application/json',
+ "Content-Length: $bodyLength",
+ 'X-Test-Header: test_value',
+ ],
+ $body,
+ null,
+ false,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+
+
+ $response = $client->post('test/path', $this->product1, $headers, ["path" => "some_path"]);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+
+ public function testPutRequest()
+ {
+ $headers = ['X-Test-Header' => 'test_value'];
+
+ $body = json_encode($this->product1);
+ $bodyLength = strlen($body);
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path?path=some_path",
+ "PUT",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ [
+ 'Content-Type: application/json',
+ "Content-Length: $bodyLength",
+ 'X-Test-Header: test_value',
+ ],
+ $body,
+ null,
+ false,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+
+
+ $response = $client->put('test/path', $this->product1, $headers, ["path" => "some_path"]);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testDeleteRequest()
+ {
+ $headers = ['X-Test-Header' => 'test_value'];
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path?path=some_path",
+ "DELETE",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ ['X-Test-Header: test_value'],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+
+ $response = $client->delete('test/path', $headers, ["path" => "some_path"]);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testPostWithStringBody()
+ {
+ $body = json_encode($this->product1);
+ $bodyLength = strlen($body);
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "POST",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ [
+ 'Content-Type: application/json',
+ "Content-Length: $bodyLength",
+ ],
+ $body,
+ null,
+ false,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+
+ $response = $client->post('test/path', $body);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testUserAgent()
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ [],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+ $client->get('test/path');
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ "^Extra user agent | My Super App (support@my-super-app.com)" .
+ "| Tiendanube Admin API Library for PHP v$this->version$",
+ [],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $client->get('test/path', ['User-Agent' => "Extra user agent"]);
+ }
+
+ public function testRequestThrowsErrorOnRequestFailure()
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ [],
+ null,
+ 'Test error!',
+ false,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+ $this->expectException(\Tiendanube\Exception\HttpRequestException::class);
+ $client->get('test/path');
+ }
+
+ public function testRetryAfterCanBeFloat()
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ // 1ms sleep time so we don't affect test run times
+ $this->buildMockHttpResponse(429, null, ['Retry-After' => 0.001]),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ null,
+ [],
+ null,
+ null,
+ true,
+ true,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+
+ $response = $client->get('test/path', [], [], 2);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testRetryLogicForAllRetriableCodes()
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(429, null, ['Retry-After' => 0]),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(500),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ null,
+ [],
+ null,
+ null,
+ true,
+ true,
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ null,
+ [],
+ null,
+ null,
+ true,
+ true,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+
+ $response = $client->get('test/path', [], [], 3);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testRetryStopsAfterReachingTheLimit()
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(500),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(500),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ null,
+ [],
+ null,
+ null,
+ true,
+ true,
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(500, null, ['X-Is-Last-Test-Request' => true]),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ null,
+ [],
+ null,
+ null,
+ true,
+ true,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+
+ $response = $client->get('test/path', [], [], 3);
+ $this->assertThat($response, new HttpResponseMatcher(500, ['X-Is-Last-Test-Request' => [true]]));
+ }
+
+ public function testRetryStopsOnNonRetriableError()
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(500),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ "^My Super App (support@my-super-app.com) | Tiendanube Admin API Library for PHP v$this->version$",
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(400, null, ['X-Is-Last-Test-Request' => true]),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ null,
+ [],
+ null,
+ null,
+ true,
+ true,
+ ),
+ ]);
+
+ $client = new Http($this->storeId);
+
+ $response = $client->get('test/path', [], [], 10);
+ $this->assertThat($response, new HttpResponseMatcher(400, ['X-Is-Last-Test-Request' => [true]]));
+ }
+
+ public function testDeprecatedRequestsAreLogged()
+ {
+ $vfsRoot = vfsStream::setup('test');
+
+ /** @var MockObject|Http */
+ $mockedClient = $this->getMockBuilder(Http::class)
+ ->setConstructorArgs([$this->storeId])
+ ->onlyMethods(['getApiDeprecationTimestampFilePath'])
+ ->getMock();
+ $mockedClient->expects($this->once())
+ ->method('getApiDeprecationTimestampFilePath')
+ ->willReturn(vfsStream::url('test/timestamp_file'));
+
+ $testLogger = new LogMock();
+ Context::$logger = $testLogger;
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(
+ 200,
+ null,
+ [HttpHeaders::X_TIENDANUBE_API_DEPRECATED_REASON => 'Test reason']
+ ),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ ),
+ ]);
+
+ $this->assertFalse($vfsRoot->hasChild('timestamp_file'));
+ $mockedClient->get('test/path');
+
+ $this->assertTrue($testLogger->hasWarningThatContains(
+ <<assertTrue($vfsRoot->hasChild('timestamp_file'));
+ }
+
+ public function testDeprecationLogBackoffPeriod()
+ {
+ vfsStream::setup('test');
+
+ /** @var MockObject|Http */
+ $mockedClient = $this->getMockBuilder(Http::class)
+ ->setConstructorArgs([$this->storeId])
+ ->onlyMethods(['getApiDeprecationTimestampFilePath'])
+ ->getMock();
+ $mockedClient->expects($this->exactly(3))
+ ->method('getApiDeprecationTimestampFilePath')
+ ->willReturn(vfsStream::url('test/timestamp_file'));
+
+ $testLogger = new LogMock();
+ Context::$logger = $testLogger;
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(
+ 200,
+ null,
+ [HttpHeaders::X_TIENDANUBE_API_DEPRECATED_REASON => 'Test reason']
+ ),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(
+ 200,
+ null,
+ [HttpHeaders::X_TIENDANUBE_API_DEPRECATED_REASON => 'Test reason']
+ ),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(
+ 200,
+ null,
+ [HttpHeaders::X_TIENDANUBE_API_DEPRECATED_REASON => 'Test reason']
+ ),
+ "https://$this->domain/" . Context::$apiVersion . "/1/test/path",
+ "GET",
+ ),
+ ]);
+
+ $this->assertCount(0, $testLogger->records);
+
+ $mockedClient->get('test/path');
+ $this->assertCount(1, $testLogger->records);
+
+ $mockedClient->get('test/path');
+ $this->assertCount(1, $testLogger->records);
+
+ // We only log once every minute, so simulate more time than having elapsed
+ file_put_contents(vfsStream::url('test/timestamp_file'), time() - 70);
+
+ $mockedClient->get('test/path');
+ $this->assertCount(2, $testLogger->records);
+ }
+}
diff --git a/tests/Clients/MockRequest.php b/tests/Clients/MockRequest.php
new file mode 100644
index 0000000..72b29a4
--- /dev/null
+++ b/tests/Clients/MockRequest.php
@@ -0,0 +1,53 @@
+response = $response;
+ $this->url = $url;
+ $this->method = $method;
+ $this->userAgent = $userAgent;
+ $this->headers = $headers;
+ $this->body = $body;
+ $this->error = $error;
+ $this->allowOtherHeaders = $allowOtherHeaders;
+ $this->isRetry = $isRetry;
+ $this->identicalBody = $identicalBody;
+ }
+}
diff --git a/tests/Clients/PageInfoTest.php b/tests/Clients/PageInfoTest.php
new file mode 100644
index 0000000..c9552dc
--- /dev/null
+++ b/tests/Clients/PageInfoTest.php
@@ -0,0 +1,46 @@
+getProductsLinkHeader(1, 3);
+
+ $pageInfo = PageInfo::fromLinkHeader($link);
+
+ $this->assertEquals(
+ new PageInfo(
+ ['test1', 'test2'],
+ $this->getProductsAdminApiPaginationUrl(1),
+ $this->getProductsAdminApiPaginationUrl(3)
+ ),
+ $pageInfo
+ );
+ $this->assertEquals(['test1', 'test2'], $pageInfo->getFields());
+ }
+
+ public function testPreviousAndNextPageQueries()
+ {
+ $pageInfo = new PageInfo(
+ ['test1', 'test2'],
+ $this->getProductsAdminApiPaginationUrl(1),
+ $this->getProductsAdminApiPaginationUrl(3)
+ );
+
+ $this->assertEquals(
+ ["per_page" => "10", "fields" => 'test1,test2', 'page' => 1],
+ $pageInfo->getPreviousPageQuery()
+ );
+ $this->assertEquals(
+ ["per_page" => "10", "fields" => 'test1,test2', 'page' => 3],
+ $pageInfo->getNextPageQuery()
+ );
+ }
+}
diff --git a/tests/Clients/PaginationTestHelper.php b/tests/Clients/PaginationTestHelper.php
new file mode 100644
index 0000000..d635396
--- /dev/null
+++ b/tests/Clients/PaginationTestHelper.php
@@ -0,0 +1,58 @@
+domain/" . Context::$apiVersion . "/$storeId/$path?$queryString";
+ }
+
+ /**
+ * Products URL link headers with fields: `per_page=10&fields=test1%2Ctest2` and appends the token
+ * @param string|null $previousPage Page number used to access previous page
+ * @param string|null $nextPage Page number used to access next page
+ *
+ * @return string
+ */
+ protected function getProductsLinkHeader(?int $previousPage = null, ?int $nextPage = null): string
+ {
+ $headers = [];
+ if ($previousPage) {
+ $previousPageUrl = $this->getProductsAdminApiPaginationUrl($previousPage);
+ $headers[] = "<$previousPageUrl>; rel=\"previous\"";
+ }
+ if ($nextPage) {
+ $nextPageUrl = $this->getProductsAdminApiPaginationUrl($nextPage);
+ $headers[] = "<$nextPageUrl>; rel=\"next\"";
+ }
+ return (implode(', ', $headers));
+ }
+
+ /**
+ * Products next or previous page URL with fields: `per_page=10&fields=test1%2Ctest2` and appends the token
+ * @param int $page Page number to access
+ *
+ * @return string
+ */
+ protected function getProductsAdminApiPaginationUrl(int $page): string
+ {
+ return "https://$this->domain/"
+ . Context::$apiVersion
+ . "/{$this->store_id}/products?per_page=10&fields=test1%2Ctest2&page={$page}";
+ }
+}
diff --git a/tests/Clients/RestTest.php b/tests/Clients/RestTest.php
new file mode 100644
index 0000000..366274c
--- /dev/null
+++ b/tests/Clients/RestTest.php
@@ -0,0 +1,289 @@
+ [
+ 'title' => 'Test Product',
+ 'amount' => 1,
+ ],
+ ];
+
+ public function testFailsToInstantiateWithoutAccessTokenForNonPrivateApps()
+ {
+ $this->expectException(\Tiendanube\Exception\MissingArgumentException::class);
+
+ new Rest($this->storeId);
+ }
+
+ public function testCanMakeGetRequest()
+ {
+ $headers = ['X-Test-Header' => 'test_value'];
+
+ $client = new Rest($this->storeId, 'dummy-token');
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/products",
+ 'GET',
+ "Tiendanube Admin API Library for PHP v$this->version",
+ ['X-Test-Header: test_value', 'Authentication: bearer dummy-token'],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $response = $client->get('products', $headers);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testAllowsFullPaths()
+ {
+ $headers = ['X-Test-Header' => 'test_value'];
+
+ $client = new Rest($this->storeId, 'dummy-token');
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/{$this->storeId}/custom_path",
+ 'GET',
+ "Tiendanube Admin API Library for PHP v$this->version",
+ ['X-Test-Header: test_value', 'Authentication: bearer dummy-token'],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $response = $client->get("custom_path", $headers);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testCanMakeGetRequestWithPathInQuery()
+ {
+ $client = new Rest($this->storeId, 'dummy-token');
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/products?path=some_path",
+ 'GET',
+ "Tiendanube Admin API Library for PHP v$this->version",
+ ['Authentication: bearer dummy-token'],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $response = $client->get('products', [], ["path" => "some_path"]);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testCanMakePostRequestWithJsonData()
+ {
+ $client = new Rest($this->storeId, 'dummy-token');
+
+ $postData = [
+ "title" => 'Test product',
+ "amount" => 10,
+ ];
+
+ $body = json_encode($postData);
+ $bodyLength = strlen($body);
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/products",
+ 'POST',
+ "Tiendanube Admin API Library for PHP v$this->version",
+ [
+ 'Content-Type: application/json',
+ "Content-Length: $bodyLength",
+ 'Authentication: bearer dummy-token',
+ ],
+ $body,
+ null,
+ false,
+ ),
+ ]);
+
+ $response = $client->post('products', $postData);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testCanMakePostRequestWithJsonDataAndPathInQuery()
+ {
+ $client = new Rest($this->storeId, 'dummy-token');
+
+ $postData = [
+ "title" => 'Test product',
+ "amount" => 10,
+ ];
+
+ $body = json_encode($postData);
+ $bodyLength = strlen($body);
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/products?path=some_path",
+ 'POST',
+ "Tiendanube Admin API Library for PHP v$this->version",
+ [
+ 'Content-Type: application/json',
+ "Content-Length: $bodyLength",
+ 'Authentication: bearer dummy-token',
+ ],
+ $body,
+ null,
+ false,
+ ),
+ ]);
+
+ $response = $client->post('products', $postData, [], ["path" => "some_path"]);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testCanMakePutRequestWithJsonData()
+ {
+ $client = new Rest($this->storeId, 'dummy-token');
+
+ $postData = [
+ "title" => 'Test product',
+ "amount" => 10,
+ ];
+
+ $body = json_encode($postData);
+ $bodyLength = strlen($body);
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/products/123?path=some_path",
+ 'PUT',
+ "Tiendanube Admin API Library for PHP v$this->version",
+ [
+ 'Content-Type: application/json',
+ "Content-Length: $bodyLength",
+ 'Authentication: bearer dummy-token',
+ ],
+ $body,
+ null,
+ false,
+ ),
+ ]);
+
+ $response = $client->put('products/123', $postData, [], ["path" => "some_path"]);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testCanMakeDeleteRequest()
+ {
+ $headers = ['X-Test-Header' => 'test_value'];
+
+ $client = new Rest($this->storeId, 'dummy-token');
+
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse),
+ "https://$this->domain/" . Context::$apiVersion . "/1/products?path=some_path",
+ 'DELETE',
+ "Tiendanube Admin API Library for PHP v$this->version",
+ ['X-Test-Header: test_value', 'Authentication: bearer dummy-token'],
+ null,
+ null,
+ false,
+ ),
+ ]);
+
+ $response = $client->delete('products', $headers, ["path" => "some_path"]);
+ $this->assertThat($response, new HttpResponseMatcher(200, [], $this->successResponse));
+ }
+
+ public function testCanRequestNextAndPreviousPagesUntilTheyRunOut()
+ {
+ $firstPageLinkHeader = $this->getProductsLinkHeader(null, 2);
+ $middlePageLinkHeader = $this->getProductsLinkHeader(1, 3);
+ $lastPageLinkHeader = $this->getProductsLinkHeader(2, null);
+
+ $this->mockTransportRequests(
+ [
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse, ['Link' => $firstPageLinkHeader]),
+ $this->getAdminApiUrl($this->storeId, "products", "per_page=10&fields=test1%2Ctest2"),
+ "GET",
+ "Tiendanube Admin API Library for PHP v",
+ ['Authentication: bearer dummy-token'],
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse, ['Link' => $middlePageLinkHeader]),
+ $this->getProductsAdminApiPaginationUrl(2),
+ "GET",
+ "Tiendanube Admin API Library for PHP v",
+ ['Authentication: bearer dummy-token'],
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse, ['Link' => $lastPageLinkHeader]),
+ $this->getProductsAdminApiPaginationUrl(3),
+ "GET",
+ "Tiendanube Admin API Library for PHP v",
+ ['Authentication: bearer dummy-token'],
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse, ['Link' => $middlePageLinkHeader]),
+ $this->getProductsAdminApiPaginationUrl(2),
+ "GET",
+ "Tiendanube Admin API Library for PHP v",
+ ['Authentication: bearer dummy-token'],
+ ),
+ new MockRequest(
+ $this->buildMockHttpResponse(200, $this->successResponse, ['Link' => $firstPageLinkHeader]),
+ $this->getProductsAdminApiPaginationUrl(1),
+ "GET",
+ "Tiendanube Admin API Library for PHP v",
+ ['Authentication: bearer dummy-token'],
+ ),
+
+ ]
+ );
+ $client = new Rest($this->storeId, 'dummy-token');
+
+ /** @var RestResponse */
+ $response = $client->get('products', [], ["per_page" => "10", "fields" => 'test1,test2']);
+ $this->assertNull($response->getPageInfo()->getPreviousPageUrl());
+
+ $this->assertTrue($response->getPageInfo()->hasNextPage());
+ /** @var RestResponse */
+ $response = $client->get('products', [], $response->getPageInfo()->getNextPageQuery());
+ /** @var RestResponse */
+ $response = $client->get('products', [], $response->getPageInfo()->getNextPageQuery());
+ $this->assertFalse($response->getPageInfo()->hasNextPage());
+ $this->assertNull($response->getPageInfo()->getNextPageUrl());
+
+
+ $this->assertTrue($response->getPageInfo()->hasPreviousPage());
+ /** @var RestResponse */
+ $response = $client->get('products', [], $response->getPageInfo()->getPreviousPageQuery());
+ /** @var RestResponse */
+ $response = $client->get('products', [], $response->getPageInfo()->getPreviousPageQuery());
+ $this->assertFalse($response->getPageInfo()->hasPreviousPage());
+ $this->assertNull($response->getPageInfo()->getPreviousPageUrl());
+ }
+}
diff --git a/tests/ContextTest.php b/tests/ContextTest.php
new file mode 100644
index 0000000..b9aba02
--- /dev/null
+++ b/tests/ContextTest.php
@@ -0,0 +1,148 @@
+assertEquals('my_api_key', Context::$apiKey);
+ $this->assertEquals('my_api_secret_key', Context::$apiSecretKey);
+ $this->assertEquals(new Scopes(['write_products', 'write_orders']), Context::$scopes);
+ $this->assertEquals('www.my-super-app.com', Context::$hostName);
+ $this->assertEquals('https', Context::$hostScheme);
+
+ // This should not trigger the exception
+ Context::throwIfUninitialized();
+ }
+
+ // Context with different values has been set up in BaseTestCase
+ public function testCanUpdateContext()
+ {
+ Context::initialize(
+ 'my_different_api_key',
+ 'my_different_api_secret_key',
+ 'www.my-super-different-app.com',
+ 'My Super App (support@my-super-app.com)',
+ ['read_products', 'read_orders'],
+ );
+
+ $this->assertEquals('my_different_api_key', Context::$apiKey);
+ $this->assertEquals('my_different_api_secret_key', Context::$apiSecretKey);
+ $this->assertEquals(new Scopes(['read_products', 'read_orders']), Context::$scopes);
+ $this->assertEquals('www.my-super-different-app.com', Context::$hostName);
+ }
+
+ public function testThrowsIfMissingArguments()
+ {
+ $this->expectException(\Tiendanube\Exception\MissingArgumentException::class);
+ $this->expectExceptionMessage(
+ 'Cannot initialize Tiendanube/Nuvemshop API Library. Missing values for: apiKey, apiSecretKey, hostName'
+ );
+ Context::initialize('', '', '', '');
+ }
+
+ public function testThrowsIfUninitialized()
+ {
+ // ReflectionClass is used in this test as isInitialized is a private static variable,
+ // which would have been set as true due to previous tests
+ $reflectedContext = new ReflectionClass('Tiendanube\Context');
+ $reflectedIsInitialized = $reflectedContext->getProperty('isInitialized');
+ $reflectedIsInitialized->setAccessible(true);
+ $reflectedIsInitialized->setValue(false);
+
+ $this->expectException(\Tiendanube\Exception\UninitializedContextException::class);
+ Context::throwIfUninitialized();
+ }
+
+
+ public function testCanAddOverrideLogger()
+ {
+ $testLogger = new LogMock();
+
+ Context::log('Logging something!', LogLevel::DEBUG);
+ $this->assertEmpty($testLogger->records);
+
+ Context::$logger = $testLogger;
+
+ Context::log('Defaults to info');
+ $this->assertTrue($testLogger->hasInfo('Defaults to info'));
+
+ Context::log('Debug log', LogLevel::DEBUG);
+ $this->assertTrue($testLogger->hasDebug('Debug log'));
+
+ Context::log('Info log', LogLevel::INFO);
+ $this->assertTrue($testLogger->hasInfo('Info log'));
+
+ Context::log('Notice log', LogLevel::NOTICE);
+ $this->assertTrue($testLogger->hasNotice('Notice log'));
+
+ Context::log('Warning log', LogLevel::WARNING);
+ $this->assertTrue($testLogger->hasWarning('Warning log'));
+
+ Context::log('Err log', LogLevel::ERROR);
+ $this->assertTrue($testLogger->hasError('Err log'));
+
+ Context::log('Crit log', LogLevel::CRITICAL);
+ $this->assertTrue($testLogger->hasCritical('Crit log'));
+
+ Context::log('Alert log', LogLevel::ALERT);
+ $this->assertTrue($testLogger->hasAlert('Alert log'));
+
+ Context::log('Emerg log', LogLevel::EMERGENCY);
+ $this->assertTrue($testLogger->hasEmergency('Emerg log'));
+ }
+
+ /**
+ * @dataProvider canSetHostSchemeProvider
+ */
+ public function testCanSetHostScheme($host, $expectedScheme, $expectedHost)
+ {
+ Context::initialize(
+ 'my_api_key',
+ 'my_api_secret_key',
+ $host,
+ 'My Super App (support@my-super-app.com)',
+ ['write_products', 'write_orders'],
+ );
+
+ $this->assertEquals($expectedHost, Context::$hostName);
+ $this->assertEquals($expectedScheme, Context::$hostScheme);
+ }
+
+ public function canSetHostSchemeProvider()
+ {
+ return [
+ ['my-super-app.com', 'https', 'my-super-app.com'],
+ ['https://my-super-app.com', 'https', 'my-super-app.com'],
+ ['http://my-super-app.com', 'http', 'my-super-app.com'],
+ ['http://localhost', 'http', 'localhost'],
+ ['http://localhost:1234', 'http', 'localhost:1234'],
+ ];
+ }
+
+ public function testFailsOnInvalidHost()
+ {
+ $this->expectException(\Tiendanube\Exception\InvalidArgumentException::class);
+ Context::initialize(
+ 'may_api_key',
+ 'my_api_secret_key',
+ 'my-super-wrong--host-!@#$%^&*',
+ 'My Super App (support@my-super-app.com)',
+ );
+ }
+}
diff --git a/tests/HttpRequestMatcher.php b/tests/HttpRequestMatcher.php
new file mode 100644
index 0000000..60fbcf1
--- /dev/null
+++ b/tests/HttpRequestMatcher.php
@@ -0,0 +1,197 @@
+url = str_replace(["[", "]"], ["%5B", "%5D"], $url);
+ $this->method = $method;
+ $this->userAgent = $userAgent;
+ $this->headers = $headers;
+ $this->body = $body;
+ $this->allowOtherHeaders = $allowOtherHeaders;
+ $this->identicalBody = $identicalBody;
+ }
+
+ protected function matches($other): bool
+ {
+ if (!($other instanceof RequestInterface)) {
+ return false;
+ }
+ return ($other->getUri() == $this->url)
+ && ($other->getMethod() === $this->method)
+ && $this->matchBody($other)
+ && $this->matchHeadersWithoutUserAgent($other)
+ && $this->matchUserAgent($other);
+ }
+
+
+ private function matchUserAgent(RequestInterface $request): bool
+ {
+ return preg_match($this->userAgent, $request->getHeaderLine('user-agent')) != false;
+ }
+
+ private function matchBody(RequestInterface $request): bool
+ {
+ $this->calculateBodyDiff($request);
+ return empty($this->bodyDiff);
+ }
+
+ private function matchHeadersWithoutUserAgent(RequestInterface $request): bool
+ {
+ $request = $request->withoutHeader('user-agent');
+ if (!$this->allowOtherHeaders && count($request->getHeaders()) != count($this->headers)) {
+ return false;
+ }
+
+ foreach ($this->headers as $expectedHeader) {
+ $header = explode(':', $expectedHeader, 2);
+
+ $matchedHeaderValue = $request->getHeaderLine(trim($header[0])) === trim($header[1]);
+ if (!($request->hasHeader(trim($header[0])) && $matchedHeaderValue)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ protected function additionalFailureDescription($other): string
+ {
+ $diff = [];
+ if ($other->getUri() != $this->url) {
+ $diff[] = $this->diffLine("URL", $this->url, $other->getUri());
+ }
+ if ($other->getMethod() !== $this->method) {
+ $diff[] = $this->diffLine("Method", $this->method, $other->getMethod());
+ }
+ if (!$this->matchBody($other)) {
+ $other->getBody()->rewind();
+ $diff[] = $this->diffLine("Method", $this->body, $other->getBody()->getContents());
+
+ $bodyDiff = json_encode($this->bodyDiff, true);
+ $diff[] = " Diff:\n $bodyDiff";
+ }
+
+ if (!$this->matchUserAgent($other)) {
+ $diff[] = $this->diffLine("User-Agent", $this->userAgent, $other->getHeaderLine('user-agent'));
+ }
+
+ if (!$this->matchHeadersWithoutUserAgent($other)) {
+ $diff[] = $this->diffLine(
+ "Headers",
+ $this->headers,
+ $other->withoutHeader('user-agent')->getHeaders()
+ );
+ }
+
+ return implode("\n", $diff);
+ }
+
+ /**
+ * @param string $header
+ * @param null|string|array $expected
+ * @param null|string|array $actual
+ */
+ private function diffLine(string $header, $expected, $actual): string
+ {
+ if (is_array($expected)) {
+ $expected = print_r($expected, true);
+ }
+ if (is_array($actual)) {
+ $actual = print_r($actual, true);
+ }
+
+ return "## $header:\n `$actual` Doesn't match expected `$expected`";
+ }
+
+
+ public function toString(): string
+ {
+ return "HttpRequestMatcher";
+ }
+
+ private function calculateBodyDiff(RequestInterface $request): void
+ {
+ $request->getBody()->rewind();
+ $contents = $request->getBody()->getContents();
+
+ $this->bodyDiff = $this->diffBody(
+ json_decode((string)$this->body, true) ?: $this->body,
+ json_decode((string)$contents, true) ?: $contents
+ );
+
+ // If the diff is empty and we're looking for identical bodies, invert the diff to ensure it's still empty
+ if ($this->identicalBody && empty($this->bodyDiff)) {
+ $this->bodyDiff = $this->diffBody(
+ json_decode((string)$contents, true) ?: $contents,
+ json_decode((string)$this->body, true) ?: $this->body
+ );
+ }
+ }
+
+ private function diffBody($body1, $body2)
+ {
+ if (!(is_array($body1) && is_array($body2))) {
+ return $body1 !== $body2;
+ }
+
+ $difference = array();
+ foreach ($body1 as $key => $value) {
+ if (is_array($value)) {
+ if (!isset($body2[$key]) || !is_array($body2[$key])) {
+ $difference[$key] = $value;
+ } else {
+ $new_diff = $this->diffBody($value, $body2[$key]);
+ if (!empty($new_diff)) {
+ $difference[$key] = $new_diff;
+ }
+ }
+ } elseif (!array_key_exists($key, $body2) || $body2[$key] !== $value) {
+ $difference[$key] = $value;
+ }
+ }
+
+ return $difference;
+ }
+}
diff --git a/tests/HttpRequestMatcherTest.php b/tests/HttpRequestMatcherTest.php
new file mode 100644
index 0000000..b275db0
--- /dev/null
+++ b/tests/HttpRequestMatcherTest.php
@@ -0,0 +1,133 @@
+request = new Request('GET', 'https://hello-world.com/something?q1=v1&q2=v2', [], 'Request-body');
+ }
+
+ public function testMatchesExactMethodAndUrlAndRegexUserAgentAndBody()
+ {
+ $this->assertThat(
+ $this->request,
+ new HttpRequestMatcher(
+ 'https://hello-world.com/something?q1=v1&q2=v2',
+ 'GET',
+ '//',
+ [],
+ 'Request-body'
+ )
+ );
+ $this->assertThat(
+ $this->request,
+ $this->logicalNot(
+ new HttpRequestMatcher(
+ 'https://hello-world.com/something?q1=v1&q2=v2',
+ 'GET',
+ '//',
+ [],
+ 'Wrong-request-body'
+ )
+ )
+ );
+ $this->assertThat(
+ $this->request,
+ $this->logicalNot(
+ new HttpRequestMatcher('https://hello-world.com/something?q1=v1&q2=v2', 'POST', '//')
+ )
+ );
+
+ $this->assertThat(
+ $this->request,
+ $this->logicalNot(
+ new HttpRequestMatcher('https://hello-world.com/something?q1=v1&q2=v2', 'GET', '/k/')
+ )
+ );
+
+ $this->assertThat(
+ $this->request,
+ $this->logicalNot(
+ new HttpRequestMatcher('https://hello-world.com/something?q1=v1&q2=v', 'GET', '//')
+ )
+ );
+ }
+
+ public function testMatcherMatchesRegexUserAgent()
+ {
+ $request = $this->request->withHeader('User-Agent', 'some-user-agent');
+ $this->assertThat(
+ $request,
+ new HttpRequestMatcher(
+ 'https://hello-world.com/something?q1=v1&q2=v2',
+ 'GET',
+ '/user/',
+ [],
+ 'Request-body'
+ )
+ );
+
+ $request = $this->request->withHeader('User-Agent', 'some-user-agent');
+ $this->assertThat(
+ $request,
+ $this->logicalNot(
+ new HttpRequestMatcher(
+ 'https://hello-world.com/something?q1=v1&q2=v2',
+ 'GET',
+ '/^user$/',
+ [],
+ 'Request-body'
+ )
+ )
+ );
+ }
+
+ public function testMatcherMatchesHeaderIgnoringUserAgentHeader()
+ {
+ $request = $this->request
+ ->withHeader('User-Agent', 'some-user-agent')
+ ->withHeader('test-header', 'test-header-value');
+ $this->assertThat(
+ $request,
+ new HttpRequestMatcher(
+ 'https://hello-world.com/something?q1=v1&q2=v2',
+ 'GET',
+ '/user/',
+ [
+ 'test-header: test-header-value',
+ 'Host: hello-world.com',
+ ],
+ 'Request-body',
+ false
+ )
+ );
+ }
+
+ public function testForHeaderContainsIfAllowOtherHeadersIsSet()
+ {
+ $request = $this->request
+ ->withHeader('User-Agent', 'some-user-agent')
+ ->withHeader('test-header', 'test-header-value')
+ ->withHeader('another-test-header', 'another-test-header-value');
+ $this->assertThat(
+ $request,
+ new HttpRequestMatcher(
+ 'https://hello-world.com/something?q1=v1&q2=v2',
+ 'GET',
+ '/user/',
+ ['test-header: test-header-value'],
+ 'Request-body',
+ )
+ );
+ }
+}
diff --git a/tests/HttpResponseMatcher.php b/tests/HttpResponseMatcher.php
new file mode 100644
index 0000000..54a5b66
--- /dev/null
+++ b/tests/HttpResponseMatcher.php
@@ -0,0 +1,76 @@
+ [value1, value2, ...]
+ * @param array|null $decodedBody Body as an array
+ */
+ public function __construct(
+ int $statusCode = 200,
+ array $headers = [],
+ ?array $decodedBody = null
+ ) {
+ $this->statusCode = $statusCode;
+ $this->headers = $headers;
+ $this->decodedBody = $decodedBody;
+ }
+
+ protected function matches($other): bool
+ {
+ if (!($other instanceof HttpResponse)) {
+ return false;
+ }
+
+ return $this->statusCode === $other->getStatusCode()
+ && $this->headers == $other->getHeaders()
+ && $this->decodedBody === $other->getDecodedBody();
+ }
+
+
+ public function toString(): string
+ {
+ return "HttpResponseMatcher";
+ }
+
+ protected function additionalFailureDescription($other): string
+ {
+ $diff = [];
+ if ($this->statusCode !== $other->getStatusCode()) {
+ $diff[] = $this->diffLine("Status Code", $this->statusCode, $other->getStatusCode());
+ }
+ if ($this->headers != $other->getHeaders()) {
+ $diff[] = $this->diffLine("Headers", $this->headers, $other->getHeaders());
+ }
+ if ($this->decodedBody != $other->getDecodedBody()) {
+ $diff[] = $this->diffLine("Decoded Body", $this->decodedBody, $other->getDecodedBody());
+ }
+
+ return implode("\n", $diff);
+ }
+
+ private function diffLine(string $header, mixed $expected, mixed $actual): string
+ {
+ $expected = print_r($expected, true);
+ $actual = print_r($actual, true);
+
+ return "## $header:\n `$actual` Doesn't match expected `$expected`";
+ }
+}
diff --git a/tests/HttpResponseMatcherTest.php b/tests/HttpResponseMatcherTest.php
new file mode 100644
index 0000000..8f42bd0
--- /dev/null
+++ b/tests/HttpResponseMatcherTest.php
@@ -0,0 +1,81 @@
+ [
+ 'title' => 'Test Product',
+ 'amount' => 1,
+ ],
+ ];
+
+ public function testMatchFailsForIncorrectDataType()
+ {
+ $matcher = new HttpResponseMatcher();
+ $this->assertThat("incorrect data type", $this->logicalNot($matcher));
+ }
+
+
+ public function testMatchPassesIfAllParametersAreEqual()
+ {
+ $response = new HttpResponse(
+ 200,
+ ['Header-1' => ['ABCD'], 'Header-2' => ['DCBA']],
+ json_encode($this->successResponse)
+ );
+ $matcher = new HttpResponseMatcher(
+ 200,
+ ['Header-1' => ['ABCD'], 'Header-2' => ['DCBA']],
+ $this->successResponse
+ );
+ $this->assertThat($response, $matcher);
+ }
+
+ public function testMatchFailsIfAnyOfTheParametersIsNotEqual()
+ {
+ $response = new HttpResponse(
+ 204,
+ ['Header-1' => ['ABCD'], 'Header-2' => ['DCBA']],
+ json_encode($this->successResponse)
+ );
+ $matcher = new HttpResponseMatcher(
+ 200,
+ ['Header-1' => ['ABCD'], 'Header-2' => ['DCBA']],
+ $this->successResponse
+ );
+
+ $this->assertThat($response, $this->logicalNot($matcher));
+
+ $response = new HttpResponse(
+ 200,
+ ['Header-1' => ['ABCD'], 'Header-2' => ['totally differnt value']],
+ json_encode($this->successResponse)
+ );
+ $matcher = new HttpResponseMatcher(
+ 200,
+ ['Header-1' => ['ABCD'], 'Header-2' => ['DCBA']],
+ $this->successResponse
+ );
+ $this->assertThat($response, $this->logicalNot($matcher));
+
+ $response = new HttpResponse(
+ 200,
+ ['Header-1' => ['ABCD'], 'Header-2' => ['DCBA']],
+ json_encode($this->successResponse)
+ );
+ $matcher = new HttpResponseMatcher(
+ 200,
+ ['Header-1' => ['ABCD'], 'Header-2' => ['DCBA']],
+ null
+ );
+ $this->assertThat($response, $this->logicalNot($matcher));
+ }
+}
diff --git a/tests/LogMock.php b/tests/LogMock.php
new file mode 100644
index 0000000..523d26e
--- /dev/null
+++ b/tests/LogMock.php
@@ -0,0 +1,144 @@
+ $level,
+ 'message' => $message,
+ 'context' => $context,
+ ];
+
+ $this->recordsByLevel[$record['level']][] = $record;
+ $this->records[] = $record;
+ }
+
+ public function hasRecords($level)
+ {
+ return isset($this->recordsByLevel[$level]);
+ }
+
+ public function hasRecord($record, $level)
+ {
+ if (is_string($record)) {
+ $record = ['message' => $record];
+ }
+ return $this->hasRecordThatPasses(function ($rec) use ($record) {
+ if ($rec['message'] !== $record['message']) {
+ return false;
+ }
+ if (isset($record['context']) && $rec['context'] !== $record['context']) {
+ return false;
+ }
+ return true;
+ }, $level);
+ }
+
+ public function hasRecordThatContains($message, $level)
+ {
+ return $this->hasRecordThatPasses(function ($rec) use ($message) {
+ return strpos($rec['message'], $message) !== false;
+ }, $level);
+ }
+
+ public function hasRecordThatMatches($regex, $level)
+ {
+ return $this->hasRecordThatPasses(function ($rec) use ($regex) {
+ return preg_match($regex, $rec['message']) > 0;
+ }, $level);
+ }
+
+ public function hasRecordThatPasses(callable $predicate, $level)
+ {
+ if (!isset($this->recordsByLevel[$level])) {
+ return false;
+ }
+ foreach ($this->recordsByLevel[$level] as $i => $rec) {
+ if (call_user_func($predicate, $rec, $i)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public function __call($method, $args)
+ {
+ if (preg_match('/(.*)(Debug|Info|Notice|Warning|Error|Critical|Alert|Emergency)(.*)/', $method, $matches) > 0) {
+ $genericMethod = $matches[1] . ('Records' !== $matches[3] ? 'Record' : '') . $matches[3];
+ $level = strtolower($matches[2]);
+ if (method_exists($this, $genericMethod)) {
+ $args[] = $level;
+ return call_user_func_array([$this, $genericMethod], $args);
+ }
+ }
+ throw new \BadMethodCallException('Call to undefined method ' . get_class($this) . '::' . $method . '()');
+ }
+
+ public function reset()
+ {
+ $this->records = [];
+ $this->recordsByLevel = [];
+ }
+}
diff --git a/tests/Rest/Adminv1/Webhookv1Test.php b/tests/Rest/Adminv1/Webhookv1Test.php
new file mode 100644
index 0000000..e44f994
--- /dev/null
+++ b/tests/Rest/Adminv1/Webhookv1Test.php
@@ -0,0 +1,69 @@
+testSession = new Session("store_id", "AAAFFF111");
+ }
+
+ public function testCreateWebhook(): void
+ {
+ $this->mockTransportRequests([
+ new MockRequest(
+ $this->buildMockHttpResponse(200, json_encode(
+ [
+ "webhook" => [
+ "id" => 123,
+ "address" => "https://www.test.com/webhook",
+ "event" => Events::CATEGORY_CREATED,
+ "created_at" => "2023-02-04T23:53:43-03:00",
+ "updated_at" => "2023-02-04T23:53:43-03:00",
+ ],
+ ]
+ )),
+ "https://api.tiendanube.com/v1/1/webhooks",
+ "POST",
+ null,
+ [
+ "X-Tiendanube-Access-Token: my_test_token",
+ ],
+ json_encode(
+ [
+ "webhook" => [
+ "address" => "https://www.test.com/webhook",
+ "topic" => Events::CATEGORY_CREATED,
+ "format" => "json",
+ ],
+ ]
+ ),
+ ),
+ ]);
+
+ $webhook = new Webhook($this->testSession);
+ $webhook->address = "https://www.test.com/webhook";
+ $webhook->topic = Events::CATEGORY_CREATED;
+ $webhook->save();
+ }
+}
diff --git a/tests/Webhooks/ProcessResponseTest.php b/tests/Webhooks/ProcessResponseTest.php
new file mode 100644
index 0000000..291cc7f
--- /dev/null
+++ b/tests/Webhooks/ProcessResponseTest.php
@@ -0,0 +1,21 @@
+assertTrue($response->isSuccess());
+ $this->assertNull($response->getErrorMessage());
+
+ $response = new ProcessResponse(false, 'Something went wrong');
+ $this->assertFalse($response->isSuccess());
+ $this->assertEquals('Something went wrong', $response->getErrorMessage());
+ }
+}
diff --git a/tests/Webhooks/RegistryTest.php b/tests/Webhooks/RegistryTest.php
new file mode 100644
index 0000000..9e39532
--- /dev/null
+++ b/tests/Webhooks/RegistryTest.php
@@ -0,0 +1,200 @@
+processHeaders = [
+ HttpHeaders::X_TIENDANUBE_HMAC => 'hM8r7V2szaFIyLhKCM9Oo3/kR4buy2h51xZPcJu0EOo=',
+ ];
+
+ $this->processBody = [
+ 'store_id' => $this->storeId,
+ 'event' => Events::PRODUCT_UPDATED,
+ 'foo' => 'bar',
+ ];
+ }
+
+ public function setUp(): void
+ {
+ parent::setUp();
+
+ // Clean up the registry for every test
+ $reflection = new ReflectionClass(Registry::class);
+ $property = $reflection->getProperty('REGISTRY');
+ $property->setAccessible(true);
+ $property->setValue([]);
+ }
+
+ public function testAddHandler()
+ {
+ $handler = $this->getMockHandler();
+ Registry::addHandler(Events::APP_UNINSTALLED, $handler);
+
+ $this->assertSame($handler, Registry::getHandler(Events::APP_UNINSTALLED));
+ }
+
+ public function testAddHandlerToExistingRegistry()
+ {
+ $handler = $this->getMockHandler();
+ Registry::addHandler(Events::APP_UNINSTALLED, $handler);
+
+ $this->assertSame($handler, Registry::getHandler(Events::APP_UNINSTALLED));
+
+ // Now add a second webhook for a different topic
+ $handler = $this->getMockHandler();
+ Registry::addHandler(Events::PRODUCT_CREATED, $handler);
+
+ $this->assertSame($handler, Registry::getHandler(Events::PRODUCT_CREATED));
+ }
+
+ public function testAddHandlerOverridesRegistry()
+ {
+ $handler = $this->getMockHandler();
+ Registry::addHandler(Events::APP_UNINSTALLED, $handler);
+
+ $this->assertSame($handler, Registry::getHandler(Events::APP_UNINSTALLED));
+
+ // Now add a second handler for the same topic
+ $handler = $this->getMockHandler();
+ Registry::addHandler(Events::APP_UNINSTALLED, $handler);
+
+ $this->assertSame($handler, Registry::getHandler(Events::APP_UNINSTALLED));
+ }
+
+ public function testCanRegisterAndUpdateWebhook()
+ {
+ }
+
+ public function testSkipsUpdateIfCallbackIsTheSame()
+ {
+ }
+
+ public function testThrowsOnRegistrationCheckError()
+ {
+ }
+
+ public function testThrowsOnRegistrationError()
+ {
+ }
+
+ public function testProcessWebhook()
+ {
+ $handler = $this->getMockHandler();
+ $handler->expects($this->once())
+ ->method('handle')
+ ->with(
+ Events::PRODUCT_UPDATED,
+ $this->storeId,
+ $this->processBody,
+ );
+
+ Registry::addHandler(Events::PRODUCT_UPDATED, $handler);
+
+ $response = Registry::process($this->processHeaders, json_encode($this->processBody));
+ $this->assertTrue($response->isSuccess());
+ $this->assertNull($response->getErrorMessage());
+ }
+
+ public function testProcessWebhookWithHandlerErrors()
+ {
+ $handler = $this->getMockHandler();
+ $handler->expects($this->once())
+ ->method('handle')
+ ->willThrowException(new Exception('Something went wrong in the handler'));
+
+ Registry::addHandler(Events::PRODUCT_UPDATED, $handler);
+
+ $response = Registry::process($this->processHeaders, json_encode($this->processBody));
+ $this->assertFalse($response->isSuccess());
+ $this->assertEquals('Something went wrong in the handler', $response->getErrorMessage());
+ }
+
+ public function testProcessThrowsErrorOnMissingBody()
+ {
+ Registry::addHandler(Events::PRODUCT_UPDATED, $this->getMockHandler());
+
+ $this->expectException(InvalidWebhookException::class);
+ Registry::process($this->processHeaders, '');
+ }
+
+ public function testProcessThrowsErrorOnMissingHmac()
+ {
+ Registry::addHandler(Events::PRODUCT_UPDATED, $this->getMockHandler());
+
+ $headers = $this->processHeaders;
+ unset($headers[HttpHeaders::X_TIENDANUBE_HMAC]);
+
+ $this->expectException(InvalidWebhookException::class);
+ Registry::process($headers, json_encode($this->processBody));
+ }
+
+ public function testProcessThrowsErrorOnMissingTopic()
+ {
+ Registry::addHandler(Events::PRODUCT_UPDATED, $this->getMockHandler());
+
+ $body = $this->processBody;
+ unset($body['event']);
+
+ $this->expectException(InvalidWebhookException::class);
+ Registry::process($this->processHeaders, json_encode($body));
+ }
+
+ public function testProcessThrowsErrorOnMissingStoreId()
+ {
+ Registry::addHandler(Events::PRODUCT_UPDATED, $this->getMockHandler());
+
+ $body = $this->processBody;
+ unset($body['store_id']);
+
+ $this->expectException(InvalidWebhookException::class);
+ Registry::process($this->processHeaders, json_encode($body));
+ }
+
+ public function testProcessThrowsErrorOnInvalidHmac()
+ {
+ Registry::addHandler(Events::PRODUCT_UPDATED, $this->getMockHandler());
+
+ $headers = $this->processHeaders;
+ $headers[HttpHeaders::X_TIENDANUBE_HMAC] = 'whoops_this_is_wrong';
+
+ $this->expectException(InvalidWebhookException::class);
+ Registry::process($headers, json_encode($this->processBody));
+ }
+
+ public function testProcessThrowsErrorOnMissingHandler()
+ {
+ $this->expectException(MissingWebhookHandlerException::class);
+ Registry::process($this->processHeaders, json_encode($this->processBody));
+ }
+
+ /**
+ * Creates a new mock handler to be used for testing.
+ *
+ * @return MockObject|Handler
+ */
+ private function getMockHandler()
+ {
+ return $this->createMock(Handler::class);
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
deleted file mode 100644
index 6123763..0000000
--- a/tests/bootstrap.php
+++ /dev/null
@@ -1,33 +0,0 @@
-response = new MockResponse($status_code, $body, $headers);
- }
-
- public function __call($method, $args){
- $this->args = $args;
-
- return $this->response;
- }
-}
-
-class MockResponse{
- public $status_code;
- public $body;
- public $headers;
- public $success;
-
- public function __construct($status_code, $body, $headers){
- $this->status_code = $status_code;
- $this->body = $body;
- $this->headers = $headers;
-
- $this->success = is_numeric($status_code) && $status_code >= 200 && $status_code < 300;
- }
-
-}
\ No newline at end of file
diff --git a/tests/phpunit.xml.dist b/tests/phpunit.xml.dist
deleted file mode 100644
index 89b8d3b..0000000
--- a/tests/phpunit.xml.dist
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
- ./
-
-
\ No newline at end of file