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