├── .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 |
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 |
374 |
375 |
--------------------------------------------------------------------------------
/art/banner.svg:
--------------------------------------------------------------------------------
1 |
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 |