├── .editorconfig ├── .github ├── CONTRIBUTING.md ├── SECURITY.md └── workflows │ ├── analyse.yml │ ├── changelog.yml │ ├── coverage.yml │ ├── style.yml │ └── tests.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── art ├── banner.svg └── footer.svg ├── composer.json ├── config └── dynamics.php ├── phpstan.neon ├── phpunit.xml ├── src ├── Actions │ ├── Availability │ │ ├── CheckAvailability.php │ │ └── RegisterUnavailability.php │ └── ResolveTokenData.php ├── Client │ ├── ClientFactory.php │ └── ClientHttpProvider.php ├── Commands │ └── TestConnection.php ├── Concerns │ ├── CanBeSerialized.php │ ├── HasCasts.php │ ├── HasData.php │ ├── HasKeys.php │ └── ValidatesData.php ├── Contracts │ ├── Availability │ │ ├── ChecksAvailability.php │ │ └── RegistersUnavailability.php │ └── ClientFactoryContract.php ├── Data │ ├── Data.php │ └── TokenData.php ├── Events │ ├── DynamicsResponseEvent.php │ └── DynamicsTimeoutEvent.php ├── Exceptions │ ├── DynamicsException.php │ ├── ModifiedException.php │ ├── NotFoundException.php │ ├── UnavailableException.php │ └── UnreachableException.php ├── Listeners │ ├── ResponseAvailabilityListener.php │ └── TimeoutAvailabilityListener.php ├── OData │ ├── BaseResource.php │ ├── Pages │ │ ├── ArchivedSalesLine.php │ │ ├── ArchivedSalesOrder.php │ │ ├── Contact.php │ │ ├── Country.php │ │ ├── Customer.php │ │ ├── Item.php │ │ ├── ItemCrossReference.php │ │ ├── ItemLedgerEntry.php │ │ ├── SalesDiscount.php │ │ ├── SalesHeader.php │ │ ├── SalesInvoice.php │ │ ├── SalesInvoiceLine.php │ │ ├── SalesLine.php │ │ ├── SalesOrder.php │ │ ├── SalesPrice.php │ │ ├── SalesShipment.php │ │ ├── SalesShipmentHeader.php │ │ ├── SalesShipmentLine.php │ │ └── ShipToAddress.php │ └── Resource.php ├── Query │ └── QueryBuilder.php └── ServiceProvider.php └── tests ├── Actions ├── Availability │ ├── CheckAvailabilityTest.php │ └── RegisterUnavailabilityTest.php └── ResolveTokenDataTest.php ├── Client ├── ClientFactoryTest.php └── ClientHttpProviderTest.php ├── Data └── DataTest.php ├── Fakes └── OData │ └── FakeResource.php ├── Listeners ├── ResponseAvailabilityListenerTest.php └── TimeoutAvailabilityListenerTest.php ├── OData ├── BaseResourceTest.php └── FakeResourceTest.php └── TestCase.php /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | charset = utf-8 3 | end_of_line = lf 4 | insert_final_newline = true 5 | indent_style = space 6 | indent_size = 4 7 | trim_trailing_whitespace = true 8 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | Please read and understand the contribution guide before creating an issue or pull request. 6 | 7 | ## Etiquette 8 | 9 | This project is open source, and as such, the maintainers give their free time to build and maintain the source code 10 | held within. They make the code freely available in the hope that it will be of use to other developers. It would be 11 | extremely unfair for them to suffer abuse or anger for their hard work. 12 | 13 | Please be considerate towards maintainers when raising issues or presenting pull requests. Let's show the 14 | world that developers are civilized and selfless people. 15 | 16 | It's the duty of the maintainer to ensure that all submissions to the project are of sufficient 17 | quality to benefit the project. Many developers have different skillsets, strengths, and weaknesses. Respect the maintainer's decision, and do not be upset or abusive if your submission is not used. 18 | 19 | ## Viability 20 | 21 | When requesting or submitting new features, first consider whether it might be useful to others. Open 22 | source projects are used by many developers, who may have entirely different needs to your own. Think about 23 | whether or not your feature is likely to be used by other users of the project. 24 | 25 | ## Procedure 26 | 27 | Before filing an issue: 28 | 29 | - Attempt to replicate the problem, to ensure that it wasn't a coincidental incident. 30 | - Check to make sure your feature suggestion isn't already present within the project. 31 | - Check the pull requests tab to ensure that the bug doesn't have a fix in progress. 32 | - Check the pull requests tab to ensure that the feature isn't already in progress. 33 | 34 | Before submitting a pull request: 35 | 36 | - Check the codebase to ensure that your feature doesn't already exist. 37 | - Check the pull requests to ensure that another person hasn't already submitted the feature or fix. 38 | 39 | ## Requirements 40 | 41 | If the project maintainer has any additional requirements, you will find them listed here. 42 | 43 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - The easiest way to apply the conventions is to install [PHP Code Sniffer](https://pear.php.net/package/PHP_CodeSniffer). 44 | 45 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 46 | 47 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 48 | 49 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](https://semver.org/). Randomly breaking public APIs is not an option. 50 | 51 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 52 | 53 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](https://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 54 | 55 | **Happy coding**! 56 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | If you discover any security related issues, please email security@justbetter.nl instead of using the issue tracker. 4 | -------------------------------------------------------------------------------- /.github/workflows/analyse.yml: -------------------------------------------------------------------------------- 1 | name: analyse 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: [8.3, 8.4] 13 | laravel: [11.*] 14 | stability: [prefer-stable] 15 | include: 16 | - laravel: 11.* 17 | testbench: 9.* 18 | 19 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: ${{ matrix.php }} 29 | extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 30 | coverage: none 31 | 32 | - name: Install dependencies 33 | run: | 34 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 35 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 36 | - name: Analyse 37 | run: composer analyse 38 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Update Changelog" 2 | 3 | on: 4 | release: 5 | types: [ published, edited, deleted ] 6 | 7 | jobs: 8 | generate: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Checkout code 13 | uses: actions/checkout@v3 14 | with: 15 | ref: ${{ github.event.release.target_commitish }} 16 | 17 | - name: Generate changelog 18 | uses: justbetter/generate-changelogs-action@main 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | with: 22 | repository: ${{ github.repository }} 23 | 24 | - name: Commit CHANGELOG 25 | uses: stefanzweifel/git-auto-commit-action@v4 26 | with: 27 | branch: ${{ github.event.release.target_commitish }} 28 | commit_message: Update CHANGELOG 29 | file_pattern: CHANGELOG.md -------------------------------------------------------------------------------- /.github/workflows/coverage.yml: -------------------------------------------------------------------------------- 1 | name: coverage 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: [8.4] 13 | laravel: [11.*] 14 | stability: [prefer-stable] 15 | include: 16 | - laravel: 11.* 17 | testbench: 9.* 18 | 19 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: ${{ matrix.php }} 29 | extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, xdebug 30 | coverage: xdebug 31 | 32 | - name: Install dependencies 33 | run: | 34 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 35 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 36 | - name: Execute tests 37 | run: composer coverage 38 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: style 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | jobs: 8 | style: 9 | name: Style 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Setup PHP 17 | uses: shivammathur/setup-php@v2 18 | with: 19 | php-version: 8.4 20 | extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 21 | coverage: none 22 | 23 | - name: Install dependencies 24 | run: composer install 25 | 26 | - name: Style 27 | run: composer fix-style 28 | 29 | - name: Commit Changes 30 | uses: stefanzweifel/git-auto-commit-action@v4 31 | with: 32 | commit_message: Fix styling changes 33 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | test: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: true 10 | matrix: 11 | os: [ubuntu-latest] 12 | php: [8.3, 8.4] 13 | laravel: [11.*] 14 | stability: [prefer-lowest, prefer-stable] 15 | include: 16 | - laravel: 11.* 17 | testbench: 9.* 18 | 19 | name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} 20 | 21 | steps: 22 | - name: Checkout code 23 | uses: actions/checkout@v4 24 | 25 | - name: Setup PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: ${{ matrix.php }} 29 | extensions: dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo 30 | coverage: none 31 | 32 | - name: Install dependencies 33 | run: | 34 | composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update 35 | composer update --${{ matrix.stability }} --prefer-dist --no-interaction 36 | - name: Execute tests 37 | run: composer test 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | composer.lock 3 | .phpunit.result.cache 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | [Unreleased changes](https://github.com/justbetter/laravel-dynamics-client/compare/1.9.0...main) 4 | ## [1.9.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.9.0) - 2025-02-13 5 | 6 | ### What's Changed 7 | * Laravel 12 support by @VincentBean in https://github.com/justbetter/laravel-dynamics-client/pull/33 8 | 9 | 10 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.8.2...1.9.0 11 | 12 | ## [1.8.2](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.8.2) - 2025-02-12 13 | 14 | ### What's Changed 15 | * Support timeout by @VincentBean in https://github.com/justbetter/laravel-dynamics-client/pull/32 16 | 17 | 18 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.8.1...1.8.2 19 | 20 | ## [1.8.1](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.8.1) - 2025-01-24 21 | 22 | ### What's Changed 23 | * render 404 so it wont appear in logs by @fritsjustbetter in https://github.com/justbetter/laravel-dynamics-client/pull/31 24 | 25 | ### New Contributors 26 | * @fritsjustbetter made their first contribution in https://github.com/justbetter/laravel-dynamics-client/pull/31 27 | 28 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.8.0...1.8.1 29 | 30 | ## [1.8.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.8.0) - 2024-11-12 31 | 32 | ### What's Changed 33 | * Add availability by @VincentBean in https://github.com/justbetter/laravel-dynamics-client/pull/28 34 | 35 | 36 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.7.4...1.8.0 37 | 38 | ## [1.7.4](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.7.4) - 2024-08-15 39 | 40 | ### What's Changed 41 | * Add whereNotIn method by @VincentBean in https://github.com/justbetter/laravel-dynamics-client/pull/27 42 | 43 | 44 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.7.3...1.7.4 45 | 46 | ## [1.7.3](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.7.3) - 2024-08-08 47 | 48 | ### What's Changed 49 | * Added the updateOrCreate method by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/26 50 | 51 | 52 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.7.2...1.7.3 53 | 54 | ## [1.7.2](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.7.2) - 2024-07-18 55 | 56 | ### What's Changed 57 | * Support GUID by @VincentBean in https://github.com/justbetter/laravel-dynamics-client/pull/25 58 | 59 | 60 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.7.1...1.7.2 61 | 62 | ## [1.7.1](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.7.1) - 2024-07-18 63 | 64 | ### What's Changed 65 | * Support company UUID by @VincentBean in https://github.com/justbetter/laravel-dynamics-client/pull/24 66 | 67 | 68 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.7.0...1.7.1 69 | 70 | ## [1.7.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.7.0) - 2024-04-29 71 | 72 | ### What's Changed 73 | * Support OAuth by @VincentBean in https://github.com/justbetter/laravel-dynamics-client/pull/23 74 | 75 | 76 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.6.0...1.7.0 77 | 78 | ## [1.6.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.6.0) - 2024-03-29 79 | 80 | ### What's Changed 81 | * Support Laravel 11 by @VincentBean in https://github.com/justbetter/laravel-dynamics-client/pull/22 82 | 83 | 84 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.5.0...1.6.0 85 | 86 | ## [1.5.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.5.0) - 2023-11-23 87 | 88 | ### What's Changed 89 | * Allow overriding the ClientFactory to support alternative Authentification methods (such as OAuth) by @tgeorgel in https://github.com/justbetter/laravel-dynamics-client/pull/19 90 | 91 | ### New Contributors 92 | * @tgeorgel made their first contribution in https://github.com/justbetter/laravel-dynamics-client/pull/19 93 | 94 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.4.0...1.5.0 95 | 96 | ## [1.4.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.4.0) - 2023-11-20 97 | 98 | ### What's Changed 99 | * Added support for relations by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/18 100 | 101 | 102 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.3.0...1.4.0 103 | 104 | ## [1.3.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.3.0) - 2023-09-27 105 | 106 | ### What's Changed 107 | * Added the count method by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/17 108 | 109 | 110 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.2.2...1.3.0 111 | 112 | ## [1.2.2](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.2.2) - 2023-07-31 113 | 114 | ### What's Changed 115 | * Added an improved doc block to the QueryBuilder by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/15 116 | 117 | 118 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.2.1...1.2.2 119 | 120 | ## [1.2.1](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.2.1) - 2023-03-24 121 | 122 | ### What's Changed 123 | * Use correct etag key for < ODataV4 by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/13 124 | * The lazy method accepts a pageSize and is always casted to an integer by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/14 125 | 126 | 127 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.2.0...1.2.1 128 | 129 | ## [1.2.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.2.0) - 2023-03-10 130 | 131 | ### What's Changed 132 | * Preserve index in lazy method by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/11 133 | * Added support for Laravel 10 by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/12 134 | 135 | 136 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.1.0...1.2.0 137 | 138 | ## [1.1.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.1.0) - 2023-02-09 139 | 140 | ### What's Changed 141 | * Fake requests to Dynamics by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/10 142 | 143 | 144 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.0.5...1.1.0 145 | 146 | ## [1.0.5](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.0.5) - 2023-01-27 147 | 148 | ### What's Changed 149 | * Added the firstOrCreate method by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/9 150 | 151 | 152 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.0.4...1.0.5 153 | 154 | ## [1.0.4](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.0.4) - 2022-12-08 155 | 156 | ### What's Changed 157 | * Merge data when patching resources by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/8 158 | 159 | 160 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.0.3...1.0.4 161 | 162 | ## [1.0.3](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.0.3) - 2022-12-06 163 | 164 | ### What's Changed 165 | * Updated etag key by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/6 166 | * Implemented date cast when using the find method by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/7 167 | 168 | 169 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.0.2...1.0.3 170 | 171 | ## [1.0.2](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.0.2) - 2022-11-30 172 | 173 | ### What's Changed 174 | * Constructor of BaseResource is now final by @ramonrietdijk 175 | 176 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.0.1...1.0.2 177 | 178 | ## [1.0.1](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.0.1) - 2022-11-25 179 | 180 | ### What's Changed 181 | * Added Larastan by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/1 182 | * Add GitHub Actions for tests, static analysis and style check by @VincentBean in https://github.com/justbetter/laravel-dynamics-client/pull/2 183 | * Updated README.md by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/3 184 | * Added banner by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/4 185 | * Added null coalescing assignment operator for the base resource by @ramonrietdijk in https://github.com/justbetter/laravel-dynamics-client/pull/5 186 | 187 | ### New Contributors 188 | * @ramonrietdijk made their first contribution in https://github.com/justbetter/laravel-dynamics-client/pull/1 189 | * @VincentBean made their first contribution in https://github.com/justbetter/laravel-dynamics-client/pull/2 190 | 191 | **Full Changelog**: https://github.com/justbetter/laravel-dynamics-client/compare/1.0.0...1.0.1 192 | 193 | ## [1.0.0](https://github.com/justbetter/laravel-dynamics-client/releases/tag/1.0.0) - 2022-07-27 194 | 195 | Initial release 196 | 197 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) JustBetter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 6 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 7 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 8 | persons to whom the Software is furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 11 | Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 14 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 15 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 16 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | Package banner 3 | 4 | 5 | # Laravel Dynamics Client 6 | 7 | This package will connect you to your Microsoft Dynamics web services via OData. Custom web services can easily be 8 | implemented and mapped to your liking. It uses the [HTTP client](https://laravel.com/docs/master/http-client) of Laravel 9 | which means that you can easily fake requests when writing tests. 10 | 11 | The way we interact with OData has been inspired by Laravel's Query Builder. 12 | 13 | ```php 14 | $customer = Customer::query()->findOrFail('1000'); 15 | 16 | $customer->update([ 17 | 'Name' => 'John Doe', 18 | ]); 19 | 20 | $customers = Customer::query() 21 | ->where('City', '=', 'Alkmaar') 22 | ->lazy(); 23 | 24 | $items = Item::query() 25 | ->whereIn('No', ['1000', '2000']) 26 | ->get(); 27 | 28 | $customer = Customer::new()->create([ 29 | 'Name' => 'Jane Doe', 30 | ]); 31 | ``` 32 | 33 | ## Installation 34 | 35 | Install the composer package. 36 | 37 | ```shell 38 | composer require justbetter/laravel-dynamics-client 39 | ``` 40 | 41 | ## Setup 42 | 43 | Publish the configuration of the package. 44 | 45 | ```shell 46 | php artisan vendor:publish --provider="JustBetter\DynamicsClient\ServiceProvider" --tag=config 47 | ``` 48 | 49 | ## Configuration 50 | 51 | Add your Dynamics credentials in the `.env`: 52 | 53 | ``` 54 | DYNAMICS_BASE_URL=https://127.0.0.1:7048/DYNAMICS 55 | DYNAMICS_VERSION=ODataV4 56 | DYNAMICS_COMPANY= 57 | DYNAMICS_USERNAME= 58 | DYNAMICS_PASSWORD= 59 | DYNAMICS_PAGE_SIZE=1000 60 | ``` 61 | 62 | Be sure the `DYNAMICS_PAGE_SIZE` is set equally to the `Max Page Size` under `OData Services` in the configuration of 63 | Dynamics. This is crucial for the functionalities of the `lazy` method of the `QueryBuilder`. 64 | 65 | ### Authentication 66 | 67 | > **Note:** Be sure that Dynamics has been properly configured for OData. 68 | 69 | This package uses NTLM authentication by default. If you are required to use basic auth or OAuth you can change this in 70 | your `.env`. 71 | 72 | ``` 73 | DYNAMICS_AUTH=basic 74 | ``` 75 | 76 | #### OAuth 77 | 78 | To setup OAuth add the following to your `.env` 79 | 80 | ```dotenv 81 | DYNAMICS_AUTH=oauth 82 | DYNAMICS_OAUTH_CLIENT_ID= 83 | DYNAMICS_OAUTH_CLIENT_SECRET= 84 | DYNAMICS_OAUTH_REDIRECT_URI= 85 | DYNAMICS_OAUTH_SCOPE= 86 | ``` 87 | 88 | When using D365 cloud with [Microsoft identity platform](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) your redirect uri will be: `https://login.microsoftonline.com//oauth2/v2.0/token` 89 | and your base url should be `https://api.businesscentral.dynamics.com/v2.0//`. 90 | 91 | ### Connections 92 | 93 | Multiple connections are supported. You can easily update your `dynamics` configuration to add as many connections as 94 | you wish. 95 | 96 | ```php 97 | // Will use the default connection. 98 | Customer::query()->first(); 99 | 100 | // Uses the supplied connection. 101 | Customer::query('other_connection')->first(); 102 | ``` 103 | 104 | By default, the client will use the `company` field to select the Dynamics company. 105 | If you wish to use the company's UUID you can simply add an `uuid` field: 106 | 107 | ```php 108 | 'Company' => [ 109 | 'base_url' => env('DYNAMICS_BASE_URL'), 110 | 'version' => env('DYNAMICS_VERSION', 'ODataV4'), 111 | 'company' => 'Company Name', 112 | 'uuid' => 'Company UUID', // The UUID will be prioritized over the company name 113 | ``` 114 | 115 | ## Adding web services 116 | 117 | Adding a web service to your configuration is easily done. Start by creating your own resource class to map te data to. 118 | 119 | ```php 120 | use JustBetter\DynamicsClient\OData\BaseResource; 121 | 122 | class Customer extends BaseResource 123 | { 124 | // 125 | } 126 | ``` 127 | 128 | ### Primary Key 129 | 130 | By default, the primary key of a resource will default to `No` as a string. You can override this by supplying the 131 | variable `$primaryKey`. 132 | 133 | ```php 134 | public array $primaryKey = [ 135 | 'Code', 136 | ]; 137 | ``` 138 | 139 | ### Data Casting 140 | 141 | Fields in resources will by default be treated as a string. For some fields, like a line number, this should be casted 142 | to an integer. 143 | 144 | ```php 145 | public array $casts = [ 146 | 'Line_No' => 'int', 147 | ]; 148 | ``` 149 | 150 | ### Registering Your Resource 151 | 152 | Lastly, you should register your resource in your configuration file to let the package know where the web service is 153 | located. This should correspond to the service name configured in Dynamics. 154 | 155 | If your resource class name is the same as the service name, no manual configuration is needed. 156 | 157 | > **Note:** Make sure your web service is published. 158 | 159 | ```php 160 | return [ 161 | 162 | /* Resource Configuration */ 163 | 'resources' => [ 164 | Customer::class => 'CustomerCard', 165 | ], 166 | 167 | ]; 168 | ``` 169 | 170 | ## Query Builder 171 | 172 | Querying data is easily done using the QueryBuilder. 173 | 174 | Using the `get` method will only return the first result page. If you wish to efficiently loop through all records, 175 | use `lazy` instead. 176 | 177 | ```php 178 | $customers = Customer::query() 179 | ->where('City', '=', 'Alkmaar') 180 | ->lazy() 181 | ->each(function(Customer $customer): void { 182 | // 183 | }); 184 | ``` 185 | 186 | See the `QueryBuilder` class for all available methods. 187 | 188 | ## Relations 189 | 190 | Any relations published on a page can be accessed as well using the resource. 191 | 192 | ```php 193 | $salesOrder = SalesOrder::query()->first(); 194 | 195 | // Get the lines via the "relation" method. 196 | $salesLines = $salesOrder->relation('Relation_Name', SalesLine::class)->get(); 197 | 198 | // Or use the "lines" helper on the SalesOrder. 199 | $salesLines = $salesOrder->lines('Relation_Name')->get(); 200 | ``` 201 | 202 | Note that the `relation` method itself returns an instance of a query builder. This means that you can add additional where-clauses like you would be able to on a regular resource. 203 | 204 | ## Creating records 205 | 206 | Create a new record. 207 | 208 | ```php 209 | Customer::new()->create([ 210 | 'Name' => 'John Doe' 211 | ]) 212 | ``` 213 | 214 | ## Updating records 215 | 216 | Update an existing record. 217 | 218 | ```php 219 | $customer = Customer::query()->find('1000'); 220 | $customer->update([ 221 | 'Name' => 'John Doe', 222 | ]); 223 | ``` 224 | 225 | ## Deleting records 226 | 227 | Delete a record. 228 | 229 | ```php 230 | $customer = Customer::query()->find('1000'); 231 | $customer->delete(); 232 | ``` 233 | 234 | ## Debugging 235 | 236 | If you wish to review your query before you sent it, you may want to use the `dd` function on the builder. 237 | 238 | ```php 239 | Customer::query() 240 | ->where('City', '=', 'Alkmaar') 241 | ->whereIn('No', ['1000', '2000']) 242 | ->dd(); 243 | 244 | // Customer?$filter=City eq 'Alkmaar' and (No eq '1000' or No eq '2000') 245 | ``` 246 | 247 | ## Commands 248 | 249 | You can run the following command to check if you can successfully connect to Dynamics. 250 | 251 | ```shell 252 | php artisan dynamics:connect {connection?} 253 | ``` 254 | 255 | ## Extending 256 | 257 | If needed, it is possible to extend the provided `ClientFactory` class by creating your own. You **must** implement the `ClientFactoryContract` interface and its methods. 258 | 259 | 260 | ```php 261 | use JustBetter\DynamicsClient\Exceptions\DynamicsException; 262 | use JustBetter\DynamicsClient\Contracts\ClientFactoryContract; 263 | 264 | class MyCustomClientFactory implements ClientFactoryContract 265 | { 266 | public function __construct(public string $connection) 267 | { 268 | $config = config('dynamics.connections.'.$connection); 269 | 270 | if (! $config) { 271 | throw new DynamicsException( 272 | __('Connection ":connection" does not exist', ['connection' => $connection]) 273 | ); 274 | } 275 | 276 | $this 277 | ->header('Authorization', 'Bearer ' . $config['access_token']) 278 | ->header('Accept', 'application/json') 279 | ->header('Content-Type', 'application/json'); 280 | } 281 | 282 | ... 283 | } 284 | ``` 285 | 286 | You will then need to bind your custom factory as the implementation of the contract, in any of your `ServiceProvider` register method : 287 | 288 | ```php 289 | app->bind(ClientFactoryContract::class, MyCustomClientFactory::class); 303 | } 304 | } 305 | ``` 306 | 307 | ## Fake requests to Dynamics 308 | 309 | When writing tests you may find yourself in the need of faking a request to Dynamics. Luckily, this packages uses the 310 | HTTP client of Laravel to make this very easy. 311 | 312 | In order to fake all requests to Dynamics, you can call the method `fake` on any resource. 313 | 314 | > The `fake` method will fake **all** requests to Dynamics, not just the endpoint of the used resource. 315 | 316 | ```php 317 | Http::response([ 337 | 'value' => [ 338 | [ 339 | '@odata.etag' => '::etag::', 340 | 'No' => '::no::', 341 | 'Description' => '::description::', 342 | ], 343 | ], 344 | ]), 345 | ]); 346 | 347 | $item = Item::query()->first(); 348 | ``` 349 | 350 | ## Availability 351 | 352 | This client can prevent requests from going to Dynamics when it is giving HTTP status codes 503, 504 or timeouts. This can be configured per connection in the `availability` settings. Enable the `throw` option to prevent any requests from going to Dynamics. 353 | 354 | ## Contributing 355 | 356 | Please see [CONTRIBUTING](.github/CONTRIBUTING.md) for details. 357 | 358 | ## Security Vulnerabilities 359 | 360 | Please review [our security policy](../../security/policy) on how to report security vulnerabilities. 361 | 362 | ## Credits 363 | 364 | - [Vincent Boon](https://github.com/VincentBean) 365 | - [Ramon Rietdijk](https://github.com/ramonrietdijk) 366 | - [All Contributors](../../contributors) 367 | 368 | ## License 369 | 370 | The MIT License (MIT). Please see [License File](LICENSE.md) for more information. 371 | 372 | 373 | Package footer 374 | 375 | -------------------------------------------------------------------------------- /art/banner.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "justbetter/laravel-dynamics-client", 3 | "description": "A client to connect with Microsoft Dynamics", 4 | "type": "package", 5 | "license": "MIT", 6 | "homepage": "https://github.com/justbetter/laravel-dynamics-client", 7 | "authors": [ 8 | { 9 | "name": "Vincent Boon", 10 | "email": "vincent@justbetter.nl", 11 | "role": "Developer" 12 | }, 13 | { 14 | "name": "Ramon Rietdijk", 15 | "email": "ramon@justbetter.nl", 16 | "role": "Developer" 17 | } 18 | ], 19 | "require": { 20 | "php": "^8.3", 21 | "justbetter/odata-client": "^1.3", 22 | "laravel/framework": "^11.0|^12.0" 23 | }, 24 | "require-dev": { 25 | "larastan/larastan": "^3.0", 26 | "laravel/pint": "^1.20", 27 | "orchestra/testbench": "^9.0", 28 | "pestphp/pest": "^3.7", 29 | "phpstan/phpstan-mockery": "^2.0", 30 | "phpunit/phpunit": "^11.5" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "JustBetter\\DynamicsClient\\": "src" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "JustBetter\\DynamicsClient\\Tests\\": "tests" 40 | } 41 | }, 42 | "scripts": { 43 | "test": "phpunit", 44 | "analyse": "phpstan --memory-limit=256M", 45 | "style": "pint --test", 46 | "quality": [ 47 | "@style", 48 | "@analyse", 49 | "@test", 50 | "@coverage" 51 | ], 52 | "fix-style": "pint", 53 | "coverage": "XDEBUG_MODE=coverage php vendor/bin/pest --coverage --min=54" 54 | }, 55 | "config": { 56 | "sort-packages": true, 57 | "allow-plugins": { 58 | "pestphp/pest-plugin": true 59 | } 60 | }, 61 | "extra": { 62 | "laravel": { 63 | "providers": [ 64 | "JustBetter\\DynamicsClient\\ServiceProvider" 65 | ] 66 | } 67 | }, 68 | "minimum-stability": "dev", 69 | "prefer-stable": true 70 | } 71 | -------------------------------------------------------------------------------- /config/dynamics.php: -------------------------------------------------------------------------------- 1 | [ 9 | Customer::class => 'CustomerCard', 10 | ], 11 | 12 | /* Default Dynamics Connection Name */ 13 | 'connection' => env('DYNAMICS_CONNECTION', 'default'), 14 | 15 | /* Available Dynamics Connections */ 16 | 'connections' => [ 17 | 'default' => [ 18 | 'base_url' => env('DYNAMICS_BASE_URL'), 19 | 'version' => env('DYNAMICS_VERSION', 'ODataV4'), 20 | 'company' => env('DYNAMICS_COMPANY'), 21 | 'username' => env('DYNAMICS_USERNAME'), 22 | 'password' => env('DYNAMICS_PASSWORD'), 23 | 'auth' => env('DYNAMICS_AUTH', 'ntlm'), 24 | 'oauth' => [ 25 | 'client_id' => env('DYNAMICS_OAUTH_CLIENT_ID'), 26 | 'client_secret' => env('DYNAMICS_OAUTH_CLIENT_SECRET'), 27 | 'redirect_uri' => env('DYNAMICS_OAUTH_REDIRECT_URI'), 28 | 'scope' => env('DYNAMICS_OAUTH_SCOPE'), 29 | 'grant_type' => env('DYNAMICS_OAUTH_GRANT_TYPE', 'client_credentials'), 30 | ], 31 | 'page_size' => env('DYNAMICS_PAGE_SIZE', 1000), 32 | 'options' => [ 33 | 'connect_timeout' => env('DYNAMICS_TIMEOUT', 30), 34 | ], 35 | 'availability' => [ 36 | /* The response codes that should trigger the availability check in addition to connection timeouts */ 37 | 'codes' => [502, 503, 504], 38 | 39 | /* The amount of failed requests before the service is marked as unavailable. */ 40 | 'threshold' => 10, 41 | 42 | /* The timespan in minutes in which the failed requests should occur. */ 43 | 'timespan' => 10, 44 | 45 | /* The cooldown in minutes after the threshold is reached. */ 46 | 'cooldown' => 2, 47 | 48 | /* Throw an exception that prevents calls to Dynamics when unavailable */ 49 | 'throw' => false, 50 | ], 51 | ], 52 | ], 53 | 54 | ]; 55 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - ./vendor/larastan/larastan/extension.neon 3 | - ./vendor/phpstan/phpstan-mockery/extension.neon 4 | 5 | parameters: 6 | paths: 7 | - src 8 | - tests 9 | level: 8 10 | ignoreErrors: 11 | - identifier: missingType.iterableValue 12 | - identifier: missingType.generics 13 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ./tests/* 6 | 7 | 8 | 9 | 10 | ./src 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/Actions/Availability/CheckAvailability.php: -------------------------------------------------------------------------------- 1 | get(static::AVAILABLE_KEY.$connection, true); 14 | } 15 | 16 | public static function bind(): void 17 | { 18 | app()->singleton(ChecksAvailability::class, static::class); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Actions/Availability/RegisterUnavailability.php: -------------------------------------------------------------------------------- 1 | get($countKey, 0); 17 | $count++; 18 | 19 | /** @var int $threshold */ 20 | $threshold = config('dynamics.connections.'.$connection.'.availability.threshold', 10); 21 | 22 | /** @var int $timespan */ 23 | $timespan = config('dynamics.connections.'.$connection.'.availability.timespan', 10); 24 | 25 | /** @var int $cooldown */ 26 | $cooldown = config('dynamics.connections.'.$connection.'.availability.cooldown', 2); 27 | 28 | cache()->put($countKey, $count, now()->addMinutes($timespan)); 29 | 30 | if ($count >= $threshold) { 31 | cache()->put(CheckAvailability::AVAILABLE_KEY.$connection, false, now()->addMinutes($cooldown)); 32 | 33 | cache()->forget($countKey); 34 | } 35 | } 36 | 37 | public static function bind(): void 38 | { 39 | app()->singleton(RegistersUnavailability::class, static::class); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Actions/ResolveTokenData.php: -------------------------------------------------------------------------------- 1 | get($cacheKey)) { 15 | return TokenData::of(decrypt($tokenData)); 16 | } 17 | 18 | $payload = [ 19 | 'client_id' => $config['client_id'], 20 | 'client_secret' => $config['client_secret'], 21 | 'redirect_uri' => $config['redirect_uri'], 22 | 'grant_type' => $config['grant_type'], 23 | 'scope' => $config['scope'], 24 | ]; 25 | 26 | $response = Http::asMultipart() 27 | ->post($config['redirect_uri'], $payload) 28 | ->throw(); 29 | 30 | $tokenData = TokenData::of( 31 | $response->json() 32 | ); 33 | 34 | $expiresAt = now()->addSeconds($tokenData->expiresIn()); 35 | 36 | cache()->remember($cacheKey, $expiresAt, fn (): string => encrypt($tokenData->toArray())); 37 | 38 | return $tokenData; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Client/ClientFactory.php: -------------------------------------------------------------------------------- 1 | $connection]) 25 | ); 26 | } 27 | 28 | $this 29 | ->options($config['options']) 30 | ->url($config['base_url'], $config['version'], "Company('".($config['uuid'] ?? $config['company'])."')") 31 | ->when( 32 | $config['auth'] === 'oauth', 33 | fn (ClientFactory $factory): ClientFactory => $factory->oauth($connection, $config) 34 | ) 35 | ->when( 36 | $config['auth'] !== 'oauth', 37 | fn (ClientFactory $factory): ClientFactory => $factory->auth($config['username'], $config['password'], $config['auth']) 38 | ) 39 | ->header('Accept', 'application/json') 40 | ->header('Content-Type', 'application/json'); 41 | } 42 | 43 | public static function make(string $connection): static 44 | { 45 | return new static($connection); 46 | } 47 | 48 | public function options(array $options): static 49 | { 50 | $this->options = $options; 51 | 52 | return $this; 53 | } 54 | 55 | public function option(string $option, mixed $value): static 56 | { 57 | $this->options[$option] = $value; 58 | 59 | return $this; 60 | } 61 | 62 | public function headers(array $headers): static 63 | { 64 | $this->options['headers'] = $headers; 65 | 66 | return $this; 67 | } 68 | 69 | public function header(string $key, string $value): static 70 | { 71 | $this->options['headers'][$key] = $value; 72 | 73 | return $this; 74 | } 75 | 76 | public function etag(string $etag): static 77 | { 78 | $this->header('If-Match', $etag); 79 | 80 | return $this; 81 | } 82 | 83 | public function url(string ...$url): static 84 | { 85 | $this->url = implode('/', $url); 86 | 87 | return $this; 88 | } 89 | 90 | public function auth(string $username, string $password, string $auth): static 91 | { 92 | $credentials = [ 93 | $username, 94 | $password, 95 | ]; 96 | 97 | if ($auth === 'ntlm') { 98 | $credentials[] = 'ntlm'; 99 | } 100 | 101 | $this->option('auth', $credentials); 102 | 103 | return $this; 104 | } 105 | 106 | public function oauth(string $connection, array $config): static 107 | { 108 | /** @var ResolveTokenData $token */ 109 | $token = app(ResolveTokenData::class); 110 | 111 | $tokenData = $token->resolve($connection, $config['oauth']); 112 | 113 | $this->header('Authorization', $tokenData->tokenType().' '.$tokenData->accessToken()); 114 | 115 | return $this; 116 | } 117 | 118 | public function fabricate(): ODataClient 119 | { 120 | $httpProvider = new ClientHttpProvider($this->connection); 121 | $httpProvider->setExtraOptions($this->options); 122 | 123 | return new ODataClient($this->url, null, $httpProvider); 124 | } 125 | 126 | public function when(bool $condition, Closure $callback): ClientFactory 127 | { 128 | if ($condition) { 129 | $callback($this); 130 | } 131 | 132 | return $this; 133 | } 134 | 135 | public static function bind(): void 136 | { 137 | app()->bind(ClientFactoryContract::class, static::class); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/Client/ClientHttpProvider.php: -------------------------------------------------------------------------------- 1 | connection.'.availability.throw', false); 29 | 30 | if ($throw && ! $this->available()) { 31 | throw new UnavailableException('The Dynamics connection "'.$this->connection.'" is currently unavailable.'); 32 | } 33 | 34 | $timeout = config('dynamics.connections.'.$this->connection.'.options.connect_timeout', 30); 35 | 36 | try { 37 | $options = $this->extra_options; 38 | 39 | if ($request->body !== null) { 40 | $options['body'] = $request->body; 41 | } 42 | 43 | $response = Http::connectTimeout($timeout) 44 | ->timeout($timeout) 45 | ->send($request->method, $request->requestUri, $options); 46 | 47 | DynamicsResponseEvent::dispatch($response, $this->connection); 48 | 49 | $response->throw(); 50 | 51 | return $response->toPsrResponse(); 52 | } catch (RequestException $exception) { 53 | $message = $exception->getMessage(); 54 | $code = $exception->getCode(); 55 | 56 | $mapping = match ($code) { 57 | 404 => NotFoundException::class, 58 | 412 => ModifiedException::class, 59 | default => DynamicsException::class, 60 | }; 61 | 62 | /** @var DynamicsException $dynamicsException */ 63 | $dynamicsException = new $mapping($message, $code, $exception); 64 | 65 | throw $dynamicsException 66 | ->setRequest($request) 67 | ->setResponse($exception->response); 68 | } catch (ConnectionException $e) { 69 | DynamicsTimeoutEvent::dispatch($this->connection); 70 | 71 | throw $e; 72 | } 73 | } 74 | 75 | protected function available(): bool 76 | { 77 | /** @var ChecksAvailability $checker */ 78 | $checker = app(ChecksAvailability::class); 79 | 80 | return $checker->check($this->connection); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Commands/TestConnection.php: -------------------------------------------------------------------------------- 1 | argument('connection') ?? config('dynamics.connection'); 18 | 19 | $client = ClientFactory::make($connection)->fabricate(); 20 | 21 | /** @phpstan-ignore-next-line */ 22 | $client->setEntityReturnType(false); 23 | 24 | /** @var ODataResponse $response */ 25 | $response = $client->get(''); 26 | 27 | $company = $response->getBody(); 28 | 29 | $this->info('Successfully connected to company "'.$company['Name'].'"'); 30 | 31 | return static::SUCCESS; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Concerns/CanBeSerialized.php: -------------------------------------------------------------------------------- 1 | $this->connection, 11 | 'endpoint' => $this->endpoint, 12 | 'data' => $this->getIdentifierData(), 13 | ]; 14 | } 15 | 16 | public function __unserialize(array $data): void 17 | { 18 | $this 19 | ->setConnection($data['connection']) 20 | ->setEndpoint($data['endpoint']) 21 | ->setData($data['data']) 22 | ->refresh(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Concerns/HasCasts.php: -------------------------------------------------------------------------------- 1 | casts[$key] ?? null; 12 | } 13 | 14 | public function cast(string $key, mixed $value): string 15 | { 16 | return match ($this->getCastType($key)) { 17 | 'int', 'date', 'decimal', 'guid' => (string) $value, 18 | default => '\''.$value.'\'', 19 | }; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Concerns/HasData.php: -------------------------------------------------------------------------------- 1 | data; 12 | } 13 | 14 | public function setData(array $data): static 15 | { 16 | $this->data = $data; 17 | 18 | return $this; 19 | } 20 | 21 | public function merge(array $data): static 22 | { 23 | $this->data = array_merge($this->data, $data); 24 | 25 | return $this; 26 | } 27 | 28 | public function offsetExists(mixed $offset): bool 29 | { 30 | return array_key_exists($offset, $this->data); 31 | } 32 | 33 | public function offsetGet(mixed $offset): mixed 34 | { 35 | return $this->data[$offset]; 36 | } 37 | 38 | public function offsetSet($offset, $value): void 39 | { 40 | if (is_null($offset)) { 41 | return; 42 | } 43 | 44 | $this->data[$offset] = $value; 45 | } 46 | 47 | public function offsetUnset(mixed $offset): void 48 | { 49 | unset($this->data[$offset]); 50 | } 51 | 52 | public function toArray(): array 53 | { 54 | return $this->data; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Concerns/HasKeys.php: -------------------------------------------------------------------------------- 1 | primaryKey) 17 | ->mapWithKeys(fn (string $key): array => [$key => $this[$key]]) 18 | ->toArray(); 19 | } 20 | 21 | public function getIdentifierString(): string 22 | { 23 | $values = collect($this->getIdentifierData()); 24 | 25 | $includeKeyNames = $this->includeKeyNames && $values->count() > 1; 26 | 27 | return $values 28 | ->filter(fn (mixed $value): bool => $value !== null) 29 | ->map(function (mixed $value, string $key) use ($includeKeyNames): string { 30 | $cast = $this->cast($key, $value); 31 | 32 | return $includeKeyNames ? $key.'='.$cast : $cast; 33 | }) 34 | ->implode(','); 35 | } 36 | 37 | /* Full OData URL for this specific resource */ 38 | public function getResourceUrl(): string 39 | { 40 | return $this->endpoint.'('.$this->getIdentifierString().')'; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Concerns/ValidatesData.php: -------------------------------------------------------------------------------- 1 | rules)->validate(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Contracts/Availability/ChecksAvailability.php: -------------------------------------------------------------------------------- 1 | validate($data); 17 | } 18 | 19 | public function offsetExists(mixed $offset): bool 20 | { 21 | return array_key_exists($offset, $this->data); 22 | } 23 | 24 | public function offsetGet(mixed $offset): mixed 25 | { 26 | return $this->data[$offset] ?? null; 27 | } 28 | 29 | public function offsetSet(mixed $offset, mixed $value): void 30 | { 31 | $this->data[$offset] = $value; 32 | } 33 | 34 | public function offsetUnset(mixed $offset): void 35 | { 36 | unset($this->data[$offset]); 37 | } 38 | 39 | public static function of(array $data): static 40 | { 41 | return new static($data); 42 | } 43 | 44 | public function toArray(): array 45 | { 46 | return $this->data; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Data/TokenData.php: -------------------------------------------------------------------------------- 1 | 'required|string', 9 | 'expires_in' => 'required|int', 10 | 'ext_expires_in' => 'required|int', 11 | 'access_token' => 'required|string', 12 | ]; 13 | 14 | public function tokenType(): string 15 | { 16 | return $this['token_type']; 17 | } 18 | 19 | public function expiresIn(): int 20 | { 21 | return $this['expires_in']; 22 | } 23 | 24 | public function extExpiresIn(): int 25 | { 26 | return $this['ext_expires_in']; 27 | } 28 | 29 | public function accessToken(): string 30 | { 31 | return $this['access_token']; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Events/DynamicsResponseEvent.php: -------------------------------------------------------------------------------- 1 | request = $request; 18 | 19 | return $this; 20 | } 21 | 22 | public function setResponse(Response $response): static 23 | { 24 | $this->response = $response; 25 | 26 | return $this; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/Exceptions/ModifiedException.php: -------------------------------------------------------------------------------- 1 | $codes */ 15 | $codes = config('dynamics.connections.'.$event->connection.'.availability.codes', [502, 503, 504]); 16 | 17 | if (! in_array($event->response->status(), $codes)) { 18 | return; 19 | } 20 | 21 | $this->unavailability->register($event->connection); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Listeners/TimeoutAvailabilityListener.php: -------------------------------------------------------------------------------- 1 | unavailability->register($event->connection); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/OData/BaseResource.php: -------------------------------------------------------------------------------- 1 | connection ??= $connection ?? config('dynamics.connection'); 35 | $this->endpoint ??= $endpoint ?? config('dynamics.resources.'.static::class, 36 | Str::afterLast(static::class, '\\')); 37 | } 38 | 39 | public static function new(?string $connection = null, ?string $endpoint = null): static 40 | { 41 | return new static($connection, $endpoint); 42 | } 43 | 44 | public static function query(?string $connection = null, ?string $endpoint = null): QueryBuilder 45 | { 46 | return static::new($connection, $endpoint)->newQuery(); 47 | } 48 | 49 | public function setConnection(string $connection): static 50 | { 51 | $this->connection = $connection; 52 | 53 | return $this; 54 | } 55 | 56 | public function setEndpoint(string $endpoint): static 57 | { 58 | $this->endpoint = $endpoint; 59 | 60 | return $this; 61 | } 62 | 63 | public function fromEntity(Entity $entity): static 64 | { 65 | $this->setData($entity->toArray()); 66 | 67 | return $this; 68 | } 69 | 70 | public function fromPage(BaseResource $page): static 71 | { 72 | $this->setData($page->getData()); 73 | 74 | return $this; 75 | } 76 | 77 | public function create(array $data): static 78 | { 79 | /** @var array $entities */ 80 | $entities = $this->client()->post($this->endpoint, $data); 81 | 82 | if (empty($entities)) { 83 | throw new DynamicsException('No data returned after creation'); 84 | } 85 | 86 | /** @var Entity $entity */ 87 | $entity = reset($entities); 88 | 89 | return static::new($this->connection, $this->endpoint)->fromEntity($entity)->wasRecentlyCreated(); 90 | } 91 | 92 | protected function wasRecentlyCreated(bool $wasRecentlyCreated = true): static 93 | { 94 | $this->wasRecentlyCreated = $wasRecentlyCreated; 95 | 96 | return $this; 97 | } 98 | 99 | public function update(array $data, bool $force = false): static 100 | { 101 | $this 102 | ->client($this->etag($force)) 103 | ->patch($this->getResourceUrl(), $data); 104 | 105 | $this->merge($data); 106 | 107 | return $this->refresh(); 108 | } 109 | 110 | public function delete(bool $force = false): void 111 | { 112 | $this 113 | ->client($this->etag($force)) 114 | ->delete($this->getResourceUrl()); 115 | } 116 | 117 | public function refresh(): static 118 | { 119 | $values = collect($this->primaryKey) 120 | ->map(fn (string $key): mixed => $this[$key]) 121 | ->toArray(); 122 | 123 | $baseResource = static::query($this->connection, $this->endpoint)->findOrFail(...$values); 124 | 125 | return $this->fromPage($baseResource); 126 | } 127 | 128 | public function etag(bool $force = false): string 129 | { 130 | if ($force) { 131 | return '*'; 132 | } 133 | 134 | $version = config('dynamics.connections.'.$this->connection.'.version'); 135 | 136 | return $version === 'ODataV4' 137 | ? $this->data['@odata.etag'] 138 | : $this->data['odata.etag']; 139 | } 140 | 141 | public function client(?string $etag = null): ODataClient 142 | { 143 | /** @var ClientFactoryContract $factory */ 144 | $factory = app(ClientFactoryContract::class, ['connection' => $this->connection]); 145 | 146 | if ($etag) { 147 | $factory->etag($etag); 148 | } 149 | 150 | return $factory->fabricate(); 151 | } 152 | 153 | public function newQuery(): QueryBuilder 154 | { 155 | return new QueryBuilder($this->client(), $this->connection, $this->endpoint, static::class); 156 | } 157 | 158 | public function relation(string $relation, string $class): QueryBuilder 159 | { 160 | return new QueryBuilder($this->client(), $this->connection, $this->getResourceUrl().'/'.$relation, $class); 161 | } 162 | 163 | public function available(): bool 164 | { 165 | /** @var ChecksAvailability $instance */ 166 | $instance = app(ChecksAvailability::class); 167 | 168 | return $instance->check($this->connection); 169 | } 170 | 171 | public static function fake(): void 172 | { 173 | foreach (config('dynamics.connections') as $connection => $data) { 174 | config()->set('dynamics.connections.'.$connection.'.base_url', 'dynamics'); 175 | config()->set('dynamics.connections.'.$connection.'.version', 'ODataV4'); 176 | config()->set('dynamics.connections.'.$connection.'.company', $data['company'] ?? $connection); 177 | config()->set('dynamics.connections.'.$connection.'.username', 'username'); 178 | config()->set('dynamics.connections.'.$connection.'.password', 'password'); 179 | config()->set('dynamics.connections.'.$connection.'.auth', 'basic'); 180 | } 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/OData/Pages/ArchivedSalesLine.php: -------------------------------------------------------------------------------- 1 | 'int', 19 | 'Version_No' => 'int', 20 | ]; 21 | } 22 | -------------------------------------------------------------------------------- /src/OData/Pages/ArchivedSalesOrder.php: -------------------------------------------------------------------------------- 1 | 'int', 18 | 'Version_No' => 'int', 19 | ]; 20 | } 21 | -------------------------------------------------------------------------------- /src/OData/Pages/Contact.php: -------------------------------------------------------------------------------- 1 | 'int', 15 | ]; 16 | } 17 | -------------------------------------------------------------------------------- /src/OData/Pages/SalesDiscount.php: -------------------------------------------------------------------------------- 1 | 'date', 23 | 'Minimum_Quantity' => 'decimal', 24 | ]; 25 | } 26 | -------------------------------------------------------------------------------- /src/OData/Pages/SalesHeader.php: -------------------------------------------------------------------------------- 1 | 'int', 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /src/OData/Pages/SalesLine.php: -------------------------------------------------------------------------------- 1 | 'int', 17 | ]; 18 | } 19 | -------------------------------------------------------------------------------- /src/OData/Pages/SalesOrder.php: -------------------------------------------------------------------------------- 1 | relation($relation, SalesLine::class); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/OData/Pages/SalesPrice.php: -------------------------------------------------------------------------------- 1 | 'date', 22 | 'Minimum_Quantity' => 'decimal', 23 | ]; 24 | } 25 | -------------------------------------------------------------------------------- /src/OData/Pages/SalesShipment.php: -------------------------------------------------------------------------------- 1 | 'int', 16 | ]; 17 | } 18 | -------------------------------------------------------------------------------- /src/OData/Pages/ShipToAddress.php: -------------------------------------------------------------------------------- 1 | builder = (new Builder($client))->from($endpoint); 54 | } 55 | 56 | public function __call(string $name, array $arguments): mixed 57 | { 58 | if (method_exists($this, $name)) { 59 | return $this->$name(...$arguments); 60 | } 61 | 62 | $this->builder->$name(...$arguments); 63 | 64 | return $this; 65 | } 66 | 67 | public function newResourceInstance(): BaseResource 68 | { 69 | /** @var class-string $class */ 70 | $class = $this->class; 71 | 72 | return $class::new($this->connection, $this->endpoint); 73 | } 74 | 75 | public function mapToClass(Entity $entity): BaseResource 76 | { 77 | return $this->newResourceInstance()->fromEntity($entity); 78 | } 79 | 80 | public function get(): Enumerable 81 | { 82 | return $this->builder->get()->map(fn (Entity $entity): BaseResource => $this->mapToClass($entity)); 83 | } 84 | 85 | public function first(): ?BaseResource 86 | { 87 | /** @var ?Entity $entity */ 88 | $entity = $this->builder->first(); 89 | 90 | return is_null($entity) 91 | ? null 92 | : $this->mapToClass($entity); 93 | } 94 | 95 | public function firstOrFail(): BaseResource 96 | { 97 | $resource = $this->first(); 98 | 99 | if ($resource === null) { 100 | throw new NotFoundException; 101 | } 102 | 103 | return $resource; 104 | } 105 | 106 | public function find(mixed ...$values): ?BaseResource 107 | { 108 | $baseResource = $this->newResourceInstance(); 109 | 110 | $combined = array_combine($baseResource->primaryKey, $values); 111 | 112 | foreach ($combined as $key => $value) { 113 | if ($baseResource->getCastType($key) === 'date') { 114 | $this->builder->whereDate($key, '=', $value); 115 | } elseif ($baseResource->getCastType($key) === 'guid') { 116 | $this->builder->whereRaw("$key eq $value"); 117 | } else { 118 | $this->builder->where($key, '=', $value); 119 | } 120 | } 121 | 122 | /** @var ?Entity $entity */ 123 | $entity = $this->builder->first(); 124 | 125 | return is_null($entity) 126 | ? null 127 | : $this->mapToClass($entity); 128 | } 129 | 130 | public function findOrFail(mixed ...$values): BaseResource 131 | { 132 | $resource = $this->find(...$values); 133 | 134 | if ($resource === null) { 135 | throw new NotFoundException; 136 | } 137 | 138 | return $resource; 139 | } 140 | 141 | public function firstOrCreate(array $attributes = [], array $values = []): BaseResource 142 | { 143 | /** @var ?BaseResource $resource */ 144 | $resource = $this->where($attributes)->first(); 145 | 146 | if ($resource !== null) { 147 | return $resource; 148 | } 149 | 150 | $data = array_merge($attributes, $values); 151 | 152 | return $this->newResourceInstance()->create($data); 153 | } 154 | 155 | public function updateOrCreate(array $attributes = [], array $values = [], bool $force = false): BaseResource 156 | { 157 | /** @var ?BaseResource $resource */ 158 | $resource = $this->where($attributes)->first(); 159 | 160 | if ($resource !== null) { 161 | return $resource->update($values, $force); 162 | } 163 | 164 | $data = array_merge($attributes, $values); 165 | 166 | return $this->newResourceInstance()->create($data); 167 | } 168 | 169 | public function lazy(?int $pageSize = null): LazyCollection 170 | { 171 | return LazyCollection::make(function () use ($pageSize): Generator { 172 | $pageSize ??= (int) config('dynamics.connections.'.$this->connection.'.page_size'); 173 | $page = 0; 174 | 175 | $hasNext = true; 176 | 177 | while ($hasNext) { 178 | if ($page > 0) { 179 | $this->builder->skip($page * $pageSize); 180 | } 181 | 182 | $this->builder->take($pageSize); 183 | 184 | $records = $this->get(); 185 | 186 | $hasNext = $records->count() === $pageSize; 187 | 188 | foreach ($records as $record) { 189 | yield $record; 190 | } 191 | 192 | $page++; 193 | } 194 | }); 195 | } 196 | 197 | public function count(): int 198 | { 199 | /** @var ?int $count */ 200 | $count = $this->builder->take(1)->count(); 201 | $count ??= 0; 202 | 203 | return $count; 204 | } 205 | 206 | public function limit(int $limit): static 207 | { 208 | $this->builder->take($limit); 209 | 210 | return $this; 211 | } 212 | 213 | public function whereIn(string $field, array $values): static 214 | { 215 | $this->builder->where(function (Builder $builder) use ($field, $values): void { 216 | foreach (array_values($values) as $index => $value) { 217 | $method = $index === 0 ? 'where' : 'orWhere'; 218 | 219 | $builder->$method($field, '=', $value); 220 | } 221 | }); 222 | 223 | return $this; 224 | } 225 | 226 | public function whereNotIn(string $field, array $values): static 227 | { 228 | $this->builder->where(function (Builder $builder) use ($field, $values): void { 229 | foreach ($values as $value) { 230 | $builder->where($field, '!=', $value); 231 | } 232 | }); 233 | 234 | return $this; 235 | } 236 | 237 | public function when(mixed $statement, Closure $closure): static 238 | { 239 | if ($statement) { 240 | $closure($this, $statement); 241 | } 242 | 243 | return $this; 244 | } 245 | 246 | public function dd(): void 247 | { 248 | dd($this->builder->toRequest()); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /src/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | registerConfig() 22 | ->registerActions(); 23 | } 24 | 25 | protected function registerConfig(): static 26 | { 27 | $this->mergeConfigFrom(__DIR__.'/../config/dynamics.php', 'dynamics'); 28 | 29 | return $this; 30 | } 31 | 32 | protected function registerActions(): static 33 | { 34 | ClientFactory::bind(); 35 | CheckAvailability::bind(); 36 | RegisterUnavailability::bind(); 37 | 38 | return $this; 39 | } 40 | 41 | public function boot(): void 42 | { 43 | $this 44 | ->bootConfig() 45 | ->bootCommands() 46 | ->bootListeners(); 47 | } 48 | 49 | protected function bootConfig(): static 50 | { 51 | $this->publishes([ 52 | __DIR__.'/../config/dynamics.php' => config_path('dynamics.php'), 53 | ], 'config'); 54 | 55 | return $this; 56 | } 57 | 58 | protected function bootCommands(): static 59 | { 60 | if ($this->app->runningInConsole()) { 61 | $this->commands([ 62 | TestConnection::class, 63 | ]); 64 | } 65 | 66 | return $this; 67 | } 68 | 69 | protected function bootListeners(): static 70 | { 71 | Event::listen(DynamicsTimeoutEvent::class, TimeoutAvailabilityListener::class); 72 | Event::listen(DynamicsResponseEvent::class, ResponseAvailabilityListener::class); 73 | 74 | return $this; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /tests/Actions/Availability/CheckAvailabilityTest.php: -------------------------------------------------------------------------------- 1 | assertTrue($action->check('default')); 18 | 19 | cache()->put(CheckAvailability::AVAILABLE_KEY.'default', false); 20 | 21 | $this->assertFalse($action->check('default')); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/Actions/Availability/RegisterUnavailabilityTest.php: -------------------------------------------------------------------------------- 1 | register('default'); 19 | 20 | $this->assertEquals(1, cache()->get(RegisterUnavailability::COUNT_KEY.'default')); 21 | } 22 | 23 | #[Test] 24 | public function it_can_pass_threshold(): void 25 | { 26 | /** @var RegisterUnavailability $action */ 27 | $action = app(RegisterUnavailability::class); 28 | 29 | cache()->put(RegisterUnavailability::COUNT_KEY.'default', 9); 30 | 31 | $action->register('default'); 32 | 33 | $this->assertNull(cache()->get(RegisterUnavailability::COUNT_KEY.'default')); 34 | $this->assertFalse(cache()->get(CheckAvailability::AVAILABLE_KEY.'default')); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Actions/ResolveTokenDataTest.php: -------------------------------------------------------------------------------- 1 | Http::response([ 18 | 'token_type' => '::token-type::', 19 | 'expires_in' => 3600, 20 | 'ext_expires_in' => 3600, 21 | 'access_token' => '::access-token::', 22 | ]), 23 | ])->preventStrayRequests(); 24 | } 25 | 26 | #[Test] 27 | public function it_resolves_token(): void 28 | { 29 | /** @var ResolveTokenData $resolver */ 30 | $resolver = app(ResolveTokenData::class); 31 | 32 | $token = $resolver->resolve('default', [ 33 | 'client_id' => 'client_id', 34 | 'client_secret' => 'client_secret', 35 | 'redirect_uri' => 'dynamics_redirect_uri', 36 | 'scope' => 'scope', 37 | 'grant_type' => 'client_credentials', 38 | ]); 39 | 40 | $this->assertEquals('::access-token::', $token->accessToken()); 41 | $this->assertEquals('::token-type::', $token->tokenType()); 42 | $this->assertEquals(3600, $token->expiresIn()); 43 | $this->assertEquals(3600, $token->extExpiresIn()); 44 | } 45 | 46 | #[Test] 47 | public function it_resolves_from_cache(): void 48 | { 49 | $fakeTokenData = [ 50 | 'token_type' => '::token-type::', 51 | 'expires_in' => 3600, 52 | 'ext_expires_in' => 3600, 53 | 'access_token' => '::cached-token::', 54 | ]; 55 | 56 | cache()->rememberForever('dynamics-client:token:default', fn (): string => encrypt($fakeTokenData)); 57 | 58 | /** @var ResolveTokenData $resolver */ 59 | $resolver = app(ResolveTokenData::class); 60 | 61 | $token = $resolver->resolve('default', [ 62 | 'client_id' => 'client_id', 63 | 'client_secret' => 'client_secret', 64 | 'redirect_uri' => 'dynamics_redirect_uri', 65 | 'scope' => 'scope', 66 | 'grant_type' => 'client_credentials', 67 | ]); 68 | 69 | $this->assertEquals('::cached-token::', $token->accessToken()); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/Client/ClientFactoryTest.php: -------------------------------------------------------------------------------- 1 | set('dynamics.connections.default.auth', 'oauth'); 27 | config()->set('dynamics.connections.default.oauth', [ 28 | 'client_id' => 'client_id', 29 | 'client_secret' => 'client_secret', 30 | 'redirect_uri' => 'redirect_url', 31 | 'scope' => 'scope', 32 | 'grant_type' => 'client_credentials', 33 | ]); 34 | 35 | $this->mock(ResolveTokenData::class, function (MockInterface $mock): void { 36 | $mock->shouldReceive('resolve')->andReturn( 37 | TokenData::of([ 38 | 'token_type' => '::type::', 39 | 'expires_in' => 60, 40 | 'ext_expires_in' => 60, 41 | 'access_token' => '::access-token::', 42 | ]) 43 | ); 44 | }); 45 | 46 | $factory = ClientFactory::make('default'); 47 | 48 | $this->assertEquals('::type:: ::access-token::', data_get($factory->options, 'headers.Authorization')); 49 | } 50 | 51 | #[Test] 52 | public function it_sets_auth(): void 53 | { 54 | config()->set('dynamics.connections.default.auth', 'ntlm'); 55 | 56 | $factory = ClientFactory::make('default'); 57 | 58 | $this->assertEquals(['username', 'password', 'ntlm'], $factory->options['auth']); 59 | } 60 | 61 | #[Test] 62 | public function it_throws_exception_missing_config(): void 63 | { 64 | $this->expectException(DynamicsException::class); 65 | ClientFactory::make('does_not_exist'); 66 | } 67 | 68 | #[Test] 69 | public function it_can_set_headers(): void 70 | { 71 | $factory = ClientFactory::make('default'); 72 | $factory->headers(['headers']); 73 | 74 | $this->assertEquals(['headers'], $factory->options['headers']); 75 | } 76 | 77 | #[Test] 78 | public function it_can_set_etag(): void 79 | { 80 | $factory = ClientFactory::make('default'); 81 | $factory->etag('etag'); 82 | 83 | $this->assertEquals('etag', $factory->options['headers']['If-Match']); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/Client/ClientHttpProviderTest.php: -------------------------------------------------------------------------------- 1 | mock(ChecksAvailability::class, function (MockInterface $mock): void { 19 | $mock->shouldReceive('check')->with('default')->andReturnFalse(); 20 | }); 21 | 22 | config()->set('dynamics.connections.default.availability.throw', true); 23 | $provider = new ClientHttpProvider('default'); 24 | 25 | $this->expectException(UnavailableException::class); 26 | 27 | $provider->send(new HttpRequestMessage('GET', 'http://example.com')); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Data/DataTest.php: -------------------------------------------------------------------------------- 1 | '::token-type::', 17 | 'expires_in' => 3600, 18 | 'ext_expires_in' => 3600, 19 | 'access_token' => '::access-token::', 20 | ]); 21 | 22 | $this->assertTrue(isset($tokenData['access_token'])); 23 | $this->assertEquals('::access-token::', $tokenData['access_token']); 24 | 25 | $tokenData['access_token'] = '::new-access-token::'; 26 | 27 | $this->assertEquals('::new-access-token::', $tokenData['access_token']); 28 | 29 | unset($tokenData['access_token']); 30 | 31 | $this->assertNull($tokenData['access_token']); 32 | } 33 | 34 | #[Test] 35 | public function it_can_throw_exceptions(): void 36 | { 37 | $this->expectException(ValidationException::class); 38 | 39 | TokenData::of([]); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/Fakes/OData/FakeResource.php: -------------------------------------------------------------------------------- 1 | mock(RegistersUnavailability::class, function (MockInterface $mock): void { 21 | $mock->shouldNotReceive('register'); 22 | }); 23 | 24 | Http::fake([ 25 | '*' => Http::response(null, 200), 26 | ])->preventStrayRequests(); 27 | 28 | Item::query('default')->get(); 29 | } 30 | 31 | #[Test] 32 | public function it_calls_action(): void 33 | { 34 | Item::fake(); 35 | $this->mock(RegistersUnavailability::class, function (MockInterface $mock): void { 36 | $mock->shouldReceive('register')->with('default')->once(); 37 | }); 38 | 39 | Http::fake([ 40 | '*' => Http::response(null, 503), 41 | ])->preventStrayRequests(); 42 | 43 | $this->expectException(DynamicsException::class); 44 | Item::query('default')->get(); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /tests/Listeners/TimeoutAvailabilityListenerTest.php: -------------------------------------------------------------------------------- 1 | mock(RegistersUnavailability::class, function (MockInterface $mock): void { 18 | $mock->shouldReceive('register')->with('::connection::')->once(); 19 | }); 20 | 21 | /** @var TimeoutAvailabilityListener $listener */ 22 | $listener = app(TimeoutAvailabilityListener::class); 23 | 24 | $listener->handle(new DynamicsTimeoutEvent('::connection::')); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/OData/BaseResourceTest.php: -------------------------------------------------------------------------------- 1 | set('dynamics.resources', [ 17 | Customer::class => '::customer-endpoint::', 18 | ]); 19 | 20 | config()->set('dynamics.connections.::default::', [ 21 | 'base_url' => '::base_url::', 22 | 'version' => 'ODataV4', 23 | 'company' => '::company::', 24 | 'username' => '::username::', 25 | 'password' => '::password::', 26 | 'auth' => '::auth::', 27 | 'page_size' => 1000, 28 | 'options' => [ 29 | 'connect_timeout' => 5, 30 | ], 31 | ]); 32 | 33 | config()->set('dynamics.connections.::other-connection::', [ 34 | 'base_url' => '::base_url::', 35 | 'version' => 'ODataV4', 36 | 'company' => '::company::', 37 | 'username' => '::username::', 38 | 'password' => '::password::', 39 | 'auth' => '::auth::', 40 | 'page_size' => 1000, 41 | 'options' => [ 42 | 'connect_timeout' => 5, 43 | ], 44 | ]); 45 | 46 | config()->set('dynamics.connection', '::default::'); 47 | } 48 | 49 | #[Test] 50 | public function it_can_get_the_default_connection(): void 51 | { 52 | $page = new Customer; 53 | 54 | $this->assertEquals('::default::', $page->connection); 55 | } 56 | 57 | #[Test] 58 | public function it_can_get_the_default_endpoint(): void 59 | { 60 | $page = new Customer; 61 | 62 | $this->assertEquals('::customer-endpoint::', $page->endpoint); 63 | } 64 | 65 | #[Test] 66 | public function it_can_set_the_connection(): void 67 | { 68 | $page = new Customer('::other-connection::'); 69 | 70 | $this->assertEquals('::other-connection::', $page->connection); 71 | 72 | $page = Customer::new('::other-connection::'); 73 | 74 | $this->assertEquals('::other-connection::', $page->connection); 75 | } 76 | 77 | #[Test] 78 | public function it_can_set_the_endpoint(): void 79 | { 80 | $page = new Customer(null, '::other-endpoint::'); 81 | 82 | $this->assertEquals('::other-endpoint::', $page->endpoint); 83 | 84 | $page = Customer::new(null, '::other-endpoint::'); 85 | 86 | $this->assertEquals('::other-endpoint::', $page->endpoint); 87 | } 88 | 89 | #[Test] 90 | public function it_can_force_a_connection_and_endpoint(): void 91 | { 92 | $resource = FakeResource::new(); 93 | 94 | $this->assertEquals('::fake-connection::', $resource->connection); 95 | $this->assertEquals('::fake-endpoint::', $resource->endpoint); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/OData/FakeResourceTest.php: -------------------------------------------------------------------------------- 1 | Http::response([ 19 | 'value' => [ 20 | [ 21 | '@odata.etag' => '::etag::', 22 | 'No' => '::no::', 23 | 'Description' => '::description::', 24 | ], 25 | ], 26 | ]), 27 | ]); 28 | 29 | /** @var Item $item */ 30 | $item = Item::query()->first(); 31 | 32 | $this->assertEquals('::no::', $item['No']); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 |