diff --git a/.codeclimate.yml b/.codeclimate.yml deleted file mode 100644 index e2a9c4b..0000000 --- a/.codeclimate.yml +++ /dev/null @@ -1,42 +0,0 @@ -version: "2" -plugins: - git-legal: - enabled: true - fixme: - enabled: true - sonar-php: - enabled: true - config: - tests_patterns: - - src/test/** - - app/src/test/** - minimum_severity: major -checks: - argument-count: - config: - threshold: 5 - complex-logic: - config: - threshold: 10 - file-lines: - config: - threshold: 250 - method-complexity: - config: - threshold: 5 - method-count: - config: - threshold: 20 - method-lines: - config: - threshold: 25 - nested-control-flow: - config: - threshold: 4 - return-statements: - config: - threshold: 4 - similar-code: - enabled: false - identical-code: - enabled: false diff --git a/.editorconfig b/.editorconfig index 90845fa..8b4a43d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,11 +6,20 @@ root = true # Unix-style newlines with a newline ending every file [*] indent_style = space -indent_size = 2 +indent_size = 4 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true charset = utf-8 [*.md] -indent_size = 4 +indent_size = 1 + +[*.php] +indent_size = 1 + +[*.toml] +indent_size = 2 + +[*.{yml,yaml,json,neon}] +indent_size = 2 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e7aa8c8..50b4ccf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,14 +1,15 @@ +--- version: 2 updates: -- package-ecosystem: composer - directory: "/" - schedule: - interval: daily - time: "04:00" - open-pull-requests-limit: 10 - reviewers: - - otherguy - assignees: - - otherguy - labels: - - "dependabot \U0001F916" + - package-ecosystem: composer + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 10 + labels: [dependencies] + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + labels: [dependencies, github-actions] diff --git a/.github/reaction.yml b/.github/reaction.yml deleted file mode 100644 index 1aa0c1c..0000000 --- a/.github/reaction.yml +++ /dev/null @@ -1,24 +0,0 @@ -# Configuration for reaction-comments - https://github.com/dessant/reaction-comments - -# Issues and pull requests with these labels accept reaction comments. -# Set to `[]` to disable -exemptLabels: [] - -# Replace matching comments with this message, `{comment-author}` is an -# optional placeholder. Set to `false` to disable -reactionComment: > - :wave: @{comment-author}, did you mean to use - a [reaction](https://git.io/vhzhC) instead? -# Limit to only `issues` or `pulls` -# only: issues - -# Optionally, specify configuration settings just for `issues` or `pulls` -# issues: -# exemptLabels: -# - party-parrot - -# pulls: -# reactionComment: false - -# Repository to extend settings from -# _extends: repo diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index bbad62e..0000000 --- a/.github/stale.yml +++ /dev/null @@ -1,22 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 - -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 - -# Issues with these labels will never be considered stale -exemptLabels: - - WIP - - pinned - -# Label to use when marking an issue as stale -staleLabel: wontfix - -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. - -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..da4d85a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +--- +name: CI +'on': + push: + branches: [main] + pull_request: + branches: [main] +permissions: + contents: read +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - name: Markdown Lint + uses: DavidAnson/markdownlint-cli2-action@v23 + with: + globs: | + **/*.md + config: ".markdownlint.yaml" + + - name: YAML Lint + uses: karancode/yamllint-github-action@master + with: + yamllint_config_filepath: .yamllint.yml + yamllint_comment: true + env: + GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + test: + name: PHP ${{ matrix.php }} on ${{ matrix.os }} + needs: lint + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + php: ['8.3', '8.4'] + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f + with: + php-version: ${{ matrix.php }} + extensions: json, mbstring + coverage: ${{ matrix.php == '8.3' && 'pcov' || 'none' }} + tools: composer:v2 + - name: Cache Composer dependencies + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + with: + path: ~/.composer/cache + key: composer-${{ matrix.php }}-${{ hashFiles('composer.json') }} + restore-keys: composer-${{ matrix.php }}- + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + - name: Lint (Pint) + run: vendor/bin/pint --test + - name: Static analysis (PHPStan) + run: vendor/bin/phpstan analyse --no-progress + - name: Rector dry-run + run: vendor/bin/rector --dry-run + - name: Run tests + run: | + mkdir -p build + if [ "${{ matrix.php }}" = "8.3" ]; then + XDEBUG_MODE=coverage vendor/bin/phpunit + else + vendor/bin/phpunit --no-coverage + fi + - name: Upload coverage to Coveralls + if: matrix.php == '8.3' + env: + COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} + run: |- + if [ -n "$COVERALLS_REPO_TOKEN" ]; then + vendor/bin/php-coveralls --coverage_clover build/cov.xml --json_path build/coverage.json -v + else + echo "Skipping Coveralls upload — COVERALLS_REPO_TOKEN not set." + fi diff --git a/.gitignore b/.gitignore index 546aee2..114f637 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,12 @@ ### VisualStudioCode template .vscode/* -!.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json +### Local environment +.env + ### Composer template composer.phar /vendor/ @@ -15,3 +17,8 @@ composer.phar ### PHPUnit build .phpunit.result.cache + +### Local agent/tooling scratch +.code-review-graph/ +.docs/ +tasks/ diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..bbbfc0f --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,161 @@ +--- +# Default state for all rules +default: true +# Path to configuration file to extend +extends: +# MD001/heading-increment : Heading levels should only increment by one level at a time +MD001: true +# MD003/heading-style : Heading style +MD003: + style: consistent +# MD004/ul-style : Unordered list style +MD004: + style: consistent +# MD005/list-indent : Inconsistent indentation for list items at the same level +MD005: true +# MD007/ul-indent : Unordered list indentation +MD007: + indent: 2 + start_indented: false + start_indent: 2 +# MD009/no-trailing-spaces : Trailing spaces +MD009: + br_spaces: 2 + list_item_empty_lines: false + strict: false +# MD010/no-hard-tabs : Hard tabs +MD010: + code_blocks: true + ignore_code_languages: [] + spaces_per_tab: 1 +# MD011/no-reversed-links : Reversed link syntax +MD011: true +# MD012/no-multiple-blanks : Multiple consecutive blank lines +MD012: + maximum: 1 +# MD013/line-length : Line length +MD013: + line_length: 1000 + heading_line_length: 120 + code_block_line_length: 120 + code_blocks: true + tables: true + headings: true + strict: false + stern: false +# MD014/commands-show-output : Dollar signs used before commands without showing output +MD014: true + +# MD018/no-missing-space-atx : No space after hash on atx style heading +MD018: true + +# MD019/no-multiple-space-atx : Multiple spaces after hash on atx style heading +MD019: true + +# MD020/no-missing-space-closed-atx : No space inside hashes on closed atx style heading +MD020: true + +# MD021/no-multiple-space-closed-atx : Multiple spaces inside hashes on closed atx style heading +MD021: true +# MD022/blanks-around-headings : Headings should be surrounded by blank lines +MD022: + lines_above: 1 + lines_below: 1 +# MD023/heading-start-left : Headings must start at the beginning of the line +MD023: true +# MD024/no-duplicate-heading : Multiple headings with the same content +MD024: + siblings_only: true +# MD025/single-title/single-h1 : Multiple top-level headings in the same document +MD025: + level: 1 + front_matter_title: ^\s*title\s*[:=] +# MD026/no-trailing-punctuation : Trailing punctuation in heading +MD026: + punctuation: .,;:!。,;:! +# MD027/no-multiple-space-blockquote : Multiple spaces after blockquote symbol +MD027: true + +# MD028/no-blanks-blockquote : Blank line inside blockquote +MD028: true +# MD029/ol-prefix : Ordered list item prefix +MD029: + style: one_or_ordered +# MD030/list-marker-space : Spaces after list markers +MD030: + ul_single: 1 + ol_single: 1 + ul_multi: 1 + ol_multi: 1 +# MD031/blanks-around-fences : Fenced code blocks should be surrounded by blank lines +MD031: + list_items: true +# MD032/blanks-around-lists : Lists should be surrounded by blank lines +MD032: true +# MD033/no-inline-html : Inline HTML +MD033: + allowed_elements: [] +# MD034/no-bare-urls : Bare URL used +MD034: false +# MD035/hr-style : Horizontal rule style +MD035: + style: consistent +# MD036/no-emphasis-as-heading : Emphasis used instead of a heading +MD036: + punctuation: .,;:!?。,;:!? +# MD037/no-space-in-emphasis : Spaces inside emphasis markers +MD037: true + +# MD038/no-space-in-code : Spaces inside code span elements +MD038: true + +# MD039/no-space-in-links : Spaces inside link text +MD039: true +# MD040/fenced-code-language : Fenced code blocks should have a language specified +MD040: + allowed_languages: [] + language_only: false +# MD041/first-line-heading/first-line-h1 : First line in a file should be a top-level heading +MD041: + level: 1 + front_matter_title: ^\s*title\s*[:=] +# MD042/no-empty-links : No empty links +MD042: true +# MD044/proper-names : Proper names should have the correct capitalization +MD044: + names: [] + code_blocks: true + html_elements: true +# MD045/no-alt-text : Images should have alternate text (alt text) +MD045: true +# MD046/code-block-style : Code block style +MD046: + style: consistent +# MD047/single-trailing-newline : Files should end with a single newline character +MD047: true +# MD048/code-fence-style : Code fence style +MD048: + style: consistent +# MD049/emphasis-style : Emphasis style +MD049: + style: consistent +# MD050/strong-style : Strong style +MD050: + style: consistent +# MD051/link-fragments : Link fragments should be valid +MD051: true +# MD052/reference-links-images : Reference links and images should use a label that is defined +MD052: + shortcut_syntax: false +# MD053/link-image-reference-definitions : Link and image reference definitions should be needed +MD053: + ignored_definitions: + - // +# MD054/link-image-style : Link and image style +MD054: + autolink: true + inline: true + full: true + collapsed: true + shortcut: true + url_inline: true diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..4b88a02 --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,4 @@ +vendor/ +build/ +.git/ +node_modules/ diff --git a/.mergify.yml b/.mergify.yml deleted file mode 100644 index c3bd9a9..0000000 --- a/.mergify.yml +++ /dev/null @@ -1,28 +0,0 @@ -pull_request_rules: - - # Automatically merge Dependabot Pull Requests - - name: Automatic merge for Dependabot - conditions: - - author=dependabot-preview[bot] - - status-success=Travis CI - Pull Request - - base=master - actions: - merge: - method: squash - strict: smart - - # Delete branch after automatic merge - - name: Delete branch after merge - conditions: - - merged - actions: - delete_head_branch: {} - - # Remove outdated reviews - - name: Remove outdated reviews - conditions: - - base=master - actions: - dismiss_reviews: - approved: True - changes_requested: True diff --git a/.qlty/.gitignore b/.qlty/.gitignore new file mode 100644 index 0000000..3036618 --- /dev/null +++ b/.qlty/.gitignore @@ -0,0 +1,7 @@ +* +!configs +!configs/** +!hooks +!hooks/** +!qlty.toml +!.gitignore diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml new file mode 100644 index 0000000..a953214 --- /dev/null +++ b/.qlty/qlty.toml @@ -0,0 +1,108 @@ +# This file was automatically generated by `qlty init`. +# You can modify it to suit your needs. +# We recommend you to commit this file to your repository. +# +# This configuration is used by both Qlty CLI and Qlty Cloud. +# +# Qlty CLI -- Code quality toolkit for developers +# Qlty Cloud -- Fully automated Code Health Platform +# +# Try Qlty Cloud: https://qlty.sh +# +# For a guide to configuration, visit https://qlty.sh/d/config +# Or for a full reference, visit https://qlty.sh/d/qlty-toml +config_version = "0" + +exclude_patterns = [ + "rector.php", + "*_min.*", + "*-min.*", + "*.min.*", + "**/.yarn/**", + "**/*.d.ts", + "**/assets/**", + "**/bower_components/**", + "**/build/**", + "**/cache/**", + "**/config/**", + "**/db/**", + "**/deps/**", + "**/dist/**", + "**/extern/**", + "**/external/**", + "**/generated/**", + "**/Godeps/**", + "**/gradlew/**", + "**/mvnw/**", + "**/node_modules/**", + "**/protos/**", + "**/seed/**", + "**/target/**", + "**/templates/**", + "**/testdata/**", + "**/vendor/**", +] + +test_patterns = [ + "**/test/**", + "**/spec/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.*", + "**/*_spec.*", + "**/test_*.*", + "**/spec_*.*", +] + +[smells] +mode = "comment" + +[[triage]] +match.rules = ["radarlint-php:php:S1192"] +match.file_patterns = ["tests/**"] +set.ignored = true + +[[source]] +name = "default" +default = true + + +[[plugin]] +name = "actionlint" + +[[plugin]] +name = "editorconfig-checker" +mode = "comment" + +[[plugin]] +name = "osv-scanner" + +[[plugin]] +name = "php-codesniffer" +mode = "comment" + +[[plugin]] +name = "phpstan" +mode = "comment" + +# prepare_script: install composer deps so phpstan can resolve PSR-18, +# PHPUnit attributes, and project classes during sandboxed analysis. +# autoload_script: use the project's autoloader (with require-dev installed) +# instead of qlty's bare phpstan tool autoloader. +# copy_configs_into_tool_install = false: keep phpstan.neon at the project +# root so its relative `path:` rules under ignoreErrors continue to match. +[plugins.definitions.phpstan.drivers.lint] +prepare_script = "~/.qlty/cache/tools/composer/generic-42cc848a9f5e/composer.phar install --no-scripts --ignore-platform-reqs --no-interaction --no-progress" +autoload_script = "--autoload-file=vendor/autoload.php" +copy_configs_into_tool_install = false + +[[plugin]] +name = "radarlint-php" +mode = "comment" + +[[plugin]] +name = "ripgrep" +mode = "comment" + +[[plugin]] +name = "trufflehog" diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4c5eddc..0000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -language: php - -php: - - "7.3" - - "7.4" - - "8.0" - -env: - global: - # This is a write-only ID that can only post test reports. - - CC_TEST_REPORTER_ID=7d37e7331b1457f8c3a1857f8bd93a33c720f54dfd8bae66fa2a0fed468a3118 - - GIT_COMMITTED_AT=$(if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then git log -1 --pretty=format:%ct; else git log -1 --skip 1 --pretty=format:%ct; fi) - - XDEBUG_MODE=coverage - -install: - - curl -s http://getcomposer.org/installer | php - - php composer.phar install --no-interaction - - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter - - chmod +x ./cc-test-reporter - - ./cc-test-reporter before-build - -script: - - mkdir -p build - - vendor/bin/phpunit - -after_success: - - travis_retry php vendor/bin/php-coveralls --coverage_clover build/cov.xml --json_path build/coverage.json - - cp build/cov.xml clover.xml - - if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then ./cc-test-reporter after-build --coverage-input-type clover --exit-code $TRAVIS_TEST_RESULT; fi diff --git a/.yamlfix.toml b/.yamlfix.toml new file mode 100644 index 0000000..88b2d4e --- /dev/null +++ b/.yamlfix.toml @@ -0,0 +1,19 @@ +# .yamlfix.toml +# Configuration for YAML formatting and linting +allow_duplicate_keys = false +comments_min_spaces_from_content = 2 +comments_require_starting_space = true +comments_whitelines = 1 +whitelines = 1 +section_whitelines = 1 +explicit_start = true +indent_mapping = 2 +indent_offset = 2 +indent_sequence = 4 +line_length = 300 +none_representation = "" +quote_basic_values = false +quote_keys_and_basic_values = false +preserve_quotes = true +quote_representation = '\"' +sequence_style = "keep_style" diff --git a/.yamllint.yml b/.yamllint.yml new file mode 100644 index 0000000..9aaee2f --- /dev/null +++ b/.yamllint.yml @@ -0,0 +1,13 @@ +--- +extends: default +yaml-files: ['*.yaml', '*.yml'] +rules: + line-length: + max: 300 + level: warning +ignore: |- + **/node_modules + **/vendor + .git + .specify + specs/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a0d5662 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [2.0.0] — 2026-04-27 + +A full modernization. See the [README upgrade section](README.md#upgrading-from-1x) for migration steps. + +### Added + +- `Otherguy\Currency\Currency` backed enum with one case per supported ISO-4217 code, plus `displayName()` and `tryFromCode()` helpers. +- `Frankfurter` driver — free, key-less access to `api.frankfurter.dev`. +- `CurrencyApi` driver — latest, historical, and conversion support for `api.currencyapi.com`. +- `FastForex` driver — latest, historical, and conversion support for `api.fastforex.io`. +- `DriverFactory::register()` / `unregister()` / `build()` — instance-based registry for plugging in custom drivers. +- `ConversionResult::rateAsFloat()` and `allAsFloats()` helpers for callers that need legacy float output. +- `MockCurrencyDriver::withRates()` for seeding test rates. +- `composer check` aggregate script (`lint` + `analyse` + `rector` + `test`). +- GitHub Actions CI workflow (PHP 8.3 + 8.4, Pint, PHPStan, Rector, PHPUnit, Coveralls). +- PHPStan at `level: max`, Laravel Pint, Rector, all wired into CI. +- `tests/Support/MockHttpClient.php` — in-process PSR-18 test double. + +### Changed + +- **PHP 8.3+** required (was `>=7.3`). +- HTTP layer is now **PSR-18 / PSR-17**. `BaseCurrencyDriver::__construct()` takes a `Psr\Http\Client\ClientInterface` and a `Psr\Http\Message\RequestFactoryInterface`. +- `ConversionResult::rate()`, `convert()`, and `all()` now return `Brick\Math\BigDecimal` instead of `float`. +- Driver `date()`, `historical()`, and `convert()` accept `?DateTimeInterface` instead of strings/ints/intervals. +- Default protocol flipped to `https`. +- `CurrencyDriverContract` no longer declares `apiRequest()`; the base implementation is now `protected`. +- `ExchangeRatesApi` driver rewritten to target `api.apilayer.com/exchangerates_data` (the original endpoint was discontinued in 2021). `accessKey()` and `convert()` are now functional. +- `ConversionResult` properties `originalBaseCurrency` and `originalConversionRates` are `readonly`. +- `BaseCurrencyDriver::apiRequest()` uses `JSON_THROW_ON_ERROR`; failures wrap the underlying `JsonException` as `getPrevious()` on the resulting `ApiException`. +- Test suite uses namespaced classes (`Otherguy\Currency\Tests\…`) with PHPUnit `#[Test]` attributes and an in-process PSR-18 mock. + +### Deprecated + +- `Otherguy\Currency\Symbol` and its static helpers (`all()`, `name()`, `names()`). Kept as a shim that emits `E_USER_DEPRECATED` on first use; will be removed in 3.0. Use `Otherguy\Currency\Currency` instead. + +### Removed + +- `nikic/php-parser` dependency (was unused at runtime). +- Duplicate `phpunit/php-code-coverage` declaration (PHPUnit pulls it transitively). +- `Helpers\DateHelper::parse()` and `create()`. Use `new DateTimeImmutable(...)` directly. +- Travis CI configuration (`.travis.yml`), `.mergify.yml`, stale GitHub config files. + +### Fixed + +- `ExchangeRatesApi` driver no longer hits a dead endpoint. +- Currency math now uses arbitrary-precision decimals; round-trip rebasing (`setBaseCurrency`) is lossless within the configured scale (default 8). + +### Security + +- HTTP requests default to TLS. `->secure()` is a no-op toggle to HTTPS; there is no opt-out to plaintext without subclassing. + +[2.0.0]: https://github.com/otherguy/php-currency-api/releases/tag/v2.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c5f415f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,140 @@ +# Contributing + +Thanks for considering a contribution! This guide covers the local workflow and the bar this project holds itself to. + +## Getting started + +### Prerequisites + +You will need the following tools installed on your system to run the full suite of checks: + +- **PHP 8.3+** and **Composer** +- **[Lefthook](https://github.com/evilmartians/lefthook)**: Git hooks manager. +- **[markdownlint-cli2](https://github.com/DavidAnson/markdownlint-cli2)**: Markdown linter. +- **[yamllint](https://github.com/adrienverge/yamllint)**: YAML linter. +- **[Qlty](https://qlty.sh)**: Code quality toolkit. + +On macOS, you can install most of these via Homebrew: + +```bash +brew install lefthook markdownlint-cli2 yamllint +curl -L https://qlty.sh/get | sh +``` + +### Setup + +```bash +git clone https://github.com/otherguy/php-currency-api.git +cd php-currency-api +composer install +lefthook install +``` + +`composer check` runs the same gates as CI (Pint, PHPStan, Rector, PHPUnit). It should be green before you push. + +## Project commands + +| Command | What it does | +|--------------------------|-------------------------------------------------| +| `composer test` | PHPUnit | +| `composer test:coverage` | PHPUnit with coverage (requires pcov or Xdebug) | +| `composer lint` | Pint check (read-only) | +| `composer lint:fix` | Pint apply | +| `composer analyse` | PHPStan at `level: max` | +| `composer rector` | Rector dry-run | +| `composer rector:fix` | Rector apply | +| `composer check` | All four PHP checks in order | +| `markdownlint-cli2` | Lint all Markdown files | +| `yamllint .` | Lint all YAML files | +| `qlty check` | Run Qlty quality checks | + +## Code style + +- PHP **8.3+**, `declare(strict_types=1)` in every file. +- PSR-12 with 4-space indentation. Pint enforces this; don't fight it. +- Real types over `@var` docblocks. Constructor property promotion is preferred when it reads naturally. +- Names describe behavior, not implementation. No `Manager`, `Wrapper`, `Helper` unless it genuinely is one. +- Comments are evergreen — explain *why* the code looks weird, not *what* it does, and never reference past versions of the code. + +## Testing + +- Tests live under `tests/`, namespaced `Otherguy\Currency\Tests\…`. +- Use PHPUnit `#[Test]` attributes (no `/** @test */`). +- HTTP is mocked with [`tests/Support/MockHttpClient.php`](tests/Support/MockHttpClient.php), an in-process PSR-18 double. Build drivers via `tests/Support/DriverHarness.php`: + + ```php + $harness = new DriverHarness(); + $harness->http->enqueue(JsonResponse::ok('{"success":true,"rates":{"EUR":0.92}}')); + $driver = $harness->make('fixerio'); + + $result = $driver->accessKey('key')->from('USD')->to('EUR')->get(); + + $this->assertSame('0.92', (string) $result->rate(Currency::EUR)); + $this->assertStringContainsString('access_key=key', $harness->http->lastRequest()->getUri()->getQuery()); + ``` + +- Coverage target: **≥ 98% on `src/`**. New code without tests is unlikely to be merged. +- Tests must exercise real code paths. Don't write tests that only verify mock behavior. + +### Coverage driver + +`composer test:coverage` (and the `vendor/bin/phpunit` invocation in CI) needs a coverage driver loaded — without one, the suite reports `No tests executed!` because `phpunit.xml` has `failOnWarning="true"`. Two options: + +- **pcov** (recommended — faster, coverage-only): + + ```bash + brew install shivammathur/extensions/pcov@8.5 # match your PHP version + # or, if shivammathur tap is unreachable: + pecl install pcov + ``` + +- **Xdebug** (richer features, slower): + + ```bash + pecl install xdebug + ``` + +Verify with `php -m | grep -iE 'pcov|xdebug'`. CI installs Xdebug on the PHP 8.3 leg via `shivammathur/setup-php`. + +## Static analysis + +- PHPStan runs at `level: max`. If you hit a genuine `mixed` from upstream JSON, prefer narrowing with assertions or specific type guards. The existing `ignoreErrors` block in `phpstan.neon` is scoped to `src/Drivers/*.php` for unverifiable provider responses — please don't widen it. +- Rector checks are advisory in CI but blocking on PR. If Rector suggests a rewrite that loses meaning, exclude the rule rather than ignoring the diff. + +## Adding a driver + +See the [driver guide](README.md#adding-a-new-driver) in the root README for the full walkthrough. The short version: + +1. Extend `BaseCurrencyDriver`, set `$apiURL`, `$protocol`, default `$baseCurrency`. +2. Implement `get()`, `historical()`, `convert()` against the provider's endpoints. +3. Override `apiRequest()` only if the provider's error envelope differs from raw HTTP failures. +4. Register in `DriverFactory`'s built-in map (or expose via `register()` for third-party drivers). +5. Add tests under `tests/Drivers/` using `DriverHarness` and `MockHttpClient`. + +## Pull request checklist + +- [ ] `composer check` is green locally. +- [ ] New behavior has tests. +- [ ] Public API changes are documented in `README.md` and listed in `CHANGELOG.md` under `## [Unreleased]`. +- [ ] BC breaks include a README upgrade note with a before/after snippet. +- [ ] Commit messages are descriptive (`fix:` / `feat:` / `chore:` prefixes are welcome but not required). + +## Releasing (maintainers) + +1. Bump the version in the relevant `CHANGELOG.md` heading and move `[Unreleased]` items under it. +2. Tag: `git tag -s vX.Y.Z -m "Release X.Y.Z"`. +3. Push: `git push origin main --tags`. +4. Create a GitHub release pasting the changelog entry. +5. Packagist auto-syncs; verify the new version appears. + +## Reporting issues + +When filing a bug, please include: + +- PHP version (`php -v`). +- Library version (`composer show otherguy/php-currency-api`). +- The PSR-18 client you're using. +- A minimal reproduction (driver, fluent chain, observed vs. expected). +- The full exception trace if any. + +Thanks again — this library is healthier with every contribution. diff --git a/README.md b/README.md index adcc417..e67d7af 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,552 @@ -# 💱 Wrapper for popular Currency Exchange Rate APIs +# 💱 PHP Currency API -_A PHP API Wrapper to offer a unified programming interface for popular Currency Rate APIs._ +![PHP Currency API](resources/open-graph-preview.png) + +_A PHP API Wrapper offering a unified, fluent programming interface for popular currency exchange rate APIs._ [![Version](https://img.shields.io/packagist/v/otherguy/php-currency-api.svg?style=flat-square)](https://packagist.org/packages/otherguy/php-currency-api) [![Installs](https://img.shields.io/packagist/dt/otherguy/php-currency-api?color=blue&label=installs&style=flat-square)](https://packagist.org/packages/otherguy/php-currency-api) [![PHP version](https://img.shields.io/packagist/php-v/otherguy/php-currency-api?style=flat-square)](https://packagist.org/packages/otherguy/php-currency-api) -[![Travis CI](https://img.shields.io/travis/com/otherguy/php-currency-api.svg?style=flat-square)](https://travis-ci.com/otherguy/php-currency-api) -[![Coverage](https://img.shields.io/coveralls/otherguy/php-currency-api.svg?style=flat-square)](https://coveralls.io/github/otherguy/php-currency-api?branch=master) -[![Coverage](https://img.shields.io/codeclimate/coverage-letter/otherguy/php-currency-api.svg?style=flat-square)](https://codeclimate.com/github/otherguy/php-currency-api) -[![Maintainability](https://img.shields.io/codeclimate/maintainability/otherguy/php-currency-api.svg?style=flat-square)](https://codeclimate.com/github/otherguy/php-currency-api) +[![CI](https://img.shields.io/github/actions/workflow/status/otherguy/php-currency-api/ci.yml?branch=main&style=flat-square)](https://github.com/otherguy/php-currency-api/actions) +[![Coverage](https://img.shields.io/coveralls/otherguy/php-currency-api.svg?style=flat-square)](https://coveralls.io/github/otherguy/php-currency-api?branch=main) [![License](https://img.shields.io/github/license/otherguy/php-currency-api.svg?style=flat-square&color=orange)](LICENSE.md) -Dont worry about your favorite currency conversion service suddenly shutting down or switching plans on you. Switch away easily, without changing your code. +Don't worry about your favorite currency conversion service shutting down or changing plans. Switch providers without changing your code. + +## What's new in 2.0 -## Inspiration 💅 +- **PHP 8.3+** with strict types everywhere. +- **PSR-18 / PSR-17** HTTP layer — bring your own client (Guzzle, Symfony, anything PSR-compliant). +- **`brick/math` `BigDecimal`** for precise rate math instead of floats. +- **`Currency` backed enum** replaces the old `Symbol` constants class (which is kept as a deprecation shim). +- **New `frankfurter` driver** — free, no API key required. +- **New `currencyapi` and `fastforex` drivers** — provider parity with TripTally's backend FX stack. +- **Rewritten `exchangeratesapi` driver** — now points at the working `api.apilayer.com` endpoint with full `convert()` support. +- **Pluggable `DriverFactory`** — register your own provider at runtime. -I needed a currency conversion API for my travel website but could not find a good PHP package. The idea of the -[`Rackbeat/php-currency-api`](https://github.com/Rackbeat/php-currency-api) package came closest but unfortunately it -was just a stub and not implemented. +You can find detailed instructions on how to upgrade from `1.x` to `2.x` in [UPGRADING.md](UPGRADING.md). -## Features 🌈 +## Features -* Support for [multiple different APIs](#supported-apis-) through the use of drivers -* A [fluent interface](#fluent-interface) to make retrieving exchange rates convenient and fast -* Consistent return interface that is independent of the driver being used -* [Calculations](#conversion-result) can be made based on the returned data +- Multiple drivers behind a single interface — switch providers by changing one string. +- Fluent setter chain (`source`, `to`, `amount`, `date`, …) on every driver. +- `ConversionResult` value object with lossless rebasing (`setBaseCurrency()`). +- Hermetic test surface — inject any PSR-18 client, including in-memory mocks. -## Supported APIs 🌐 +## Supported APIs -| Service | Identifier | -|------------------------------------------------------|---------------------| -| [FixerIO](https://fixer.io) | `fixerio` | -| [CurrencyLayer](https://currencylayer.com) | `currencylayer` | -| [Open Exchange Rates](https://openexchangerates.org) | `openexchangerates` | -| [Exchange Rates API](https://exchangeratesapi.io) | `exchangeratesapi` | +| Service | Identifier | +|------------------------------------------------------------------------------------|---------------------| +| [Frankfurter](https://www.frankfurter.dev) | `frankfurter` | +| [FixerIO](https://fixer.io) | `fixerio` | +| [CurrencyLayer](https://currencylayer.com) | `currencylayer` | +| [Open Exchange Rates](https://openexchangerates.org) | `openexchangerates` | +| [APILayer Exchange Rates](https://apilayer.com/marketplace/exchangerates_data-api) | `exchangeratesapi` | +| [CurrencyAPI](https://currencyapi.com) | `currencyapi` | +| [fastFOREX](https://fastforex.io) | `fastforex` | -_If you want to see more services added, feel free to [open an issue](https://github.com/otherguy/php-currency-api/issues)!_ +A `mock` driver is also bundled for testing without network access. -## Prerequisites 📚 +_Want another provider? [Open an issue](https://github.com/otherguy/php-currency-api/issues) — or register a custom driver at runtime (see below)._ -* `PHP 8.x` or `PHP 7.3+` or higher (tested on both `7.3` and `7.4`) -* The [`composer`](https://getcomposer.org) dependency manager for PHP -* An account with one or more of the [API providers](#supported-apis-) listed above +## Requirements -## Installation 🚀 +- PHP **8.3** or higher. +- A PSR-18 HTTP client and PSR-17 request factory of your choice. +- An API account with the chosen provider, except for `frankfurter`. -Simply require the package using `composer` and you're good to go! +## Installation ```bash -$ composer require otherguy/php-currency-api +composer require otherguy/php-currency-api ``` -## Usage 🛠 +You also need a PSR-18 client and PSR-17 factory. The most common choice is Guzzle: -### Currency Symbol Helper +```bash +composer require guzzlehttp/guzzle http-interop/http-factory-guzzle +``` -The [`Otherguy\Currency\Symbol`](src/Symbol.php) class provides constants for each supported currency. +Alternatively, with Symfony HttpClient: -> !**Note:** You are not required to use `Otherguy\Currency\Symbol` to specify symbols. It's simply a convenience helper -> and does not need to be used. You can simply pass strings like `'USD', 'EUR', ...` to all methods. +```bash +composer require symfony/http-client nyholm/psr7 +``` + +## Quickstart ```php -// 'USD' -$symbol = Otherguy\Currency\Symbol::USD; +use Otherguy\Currency\Currency; +use Otherguy\Currency\DriverFactory; + +$result = DriverFactory::make('frankfurter') + ->from(Currency::USD) + ->to([Currency::EUR, Currency::GBP]) + ->get(); + +echo $result->rate(Currency::EUR); // BigDecimal '0.92' +echo $result->convert(100, Currency::USD, Currency::EUR); // BigDecimal '92.00' ``` -Use the `all()` method to retrieve an array of all currency symbols: +`DriverFactory::make()` auto-discovers Guzzle if it's installed and wires up a default PSR-18 client. To inject your own: ```php -// [ 'AED', 'AFN', ... 'ZWL' ] -$symbols = Otherguy\Currency\Symbol::all(); +use GuzzleHttp\Client; +use Http\Factory\Guzzle\RequestFactory; +use Otherguy\Currency\DriverFactory; + +$factory = new DriverFactory(); +$driver = $factory->build('fixerio', new Client(), new RequestFactory()); + +$result = $driver->accessKey('YOUR_KEY') + ->from(Currency::EUR) + ->to(Currency::USD) + ->get(); ``` -The `names()` method returns an associative array with currency names instead: +### Bring your own HTTP client (Symfony + nyholm/psr7) ```php -// [ 'AED' => 'United Arab Emirates Dirham', 'AFN' => 'Afghan Afghani', ... ] -$symbols = Otherguy\Currency\Symbol::names(); +use Nyholm\Psr7\Factory\Psr17Factory; +use Otherguy\Currency\DriverFactory; +use Symfony\Component\HttpClient\Psr18Client; + +$psr17 = new Psr17Factory(); +$client = new Psr18Client(); + +$driver = (new DriverFactory())->build('frankfurter', $client, $psr17); ``` -To get the name of a single currency, use the `name()` method: +## Usage + +### The `Currency` enum + +`Otherguy\Currency\Currency` is a backed enum with one case per ISO-4217 code (plus a few common crypto/precious-metal codes). ```php -// 'United States Dollar' -$symbols = Otherguy\Currency\Symbol::name(Otherguy\Currency\Symbol::USD); +use Otherguy\Currency\Currency; + +Currency::USD->value; // 'USD' +Currency::USD->displayName(); // 'United States Dollar' +Currency::tryFromCode('EUR'); // Currency::EUR +Currency::tryFromCode('XYZ'); // null +Currency::cases(); // every supported currency ``` -### Initialize API Instance +Every method that takes a currency accepts either the enum or its string code, so plain `'USD'` keeps working. + +### Setting the access key + +Most providers require authentication. `accessKey()` is sugar for `config('access_key', …)` and is wired per-driver to the right query-string parameter. ```php -$currency = Otherguy\Currency\DriverFactory::make('fixerio'); // driver identifier from supported drivers. +$driver->accessKey('YOUR_KEY'); ``` -To get a list of supported drivers, use the `getDrivers()` method: +Frankfurter has no API key — calling `accessKey()` on it throws `ApiException`. CurrencyAPI is the exception to the query-string rule: its driver sends the key in the `apikey` request header. + +Provider-specific key mapping: + +| Driver | `accessKey()` mapping | +| --- | --- | +| `fixerio` | `access_key` query parameter | +| `currencylayer` | `access_key` query parameter | +| `openexchangerates` | `app_id` query parameter | +| `exchangeratesapi` | `apikey` query parameter | +| `currencyapi` | `apikey` request header | +| `fastforex` | `api_key` query parameter | +| `frankfurter` | no key; throws `ApiException` | + +### Configuration options + +For provider-specific options use `config()`: ```php -// [ 'mock', 'fixerio', 'currencylayer', ... ] -$drivers = Otherguy\Currency\DriverFactory::getDrivers() +$driver->config('format', '1'); // CurrencyLayer pretty-printed JSON ``` -### Set Access Key +### Base currency -Most API providers require you to sign up and use your issued access key to authenticate against their API. You can -specify your access key like so: +`from()` and `source()` are aliases. ```php -$currency->accessKey('your-access-token-goes-here'); +$driver->from(Currency::USD); +$driver->source('USD'); ``` -### Set Configuration Options +Each driver has its own default base currency: `EUR` for FixerIO, APILayer Exchange Rates, and Frankfurter; `USD` for CurrencyLayer, Open Exchange Rates, CurrencyAPI, fastFOREX, and the mock driver. Most providers only allow base-currency changes on paid plans — they'll respond with an error envelope which the driver translates into `ApiException`. + +### Target currencies -To set further configuration options, you can use the `config()` method. An example is -[CurrencyLayer's JSON formatting option](https://currencylayer.com/documentation#format). +`to()` and `currencies()` are aliases. Pass a single currency, an array, or variadic arguments. Pass nothing (or an empty array) to ask for every currency the provider supports. ```php -$currency->config('format', '1'); +$driver->to(Currency::BTC); +$driver->currencies([Currency::BTC, Currency::EUR, Currency::USD]); +$driver->to(Currency::EUR, Currency::GBP); ``` -### Set Base Currency +### Latest rates -You can use either `from()` or `source()` to set the base currency. The methods are identical. +```php +$driver->get(); // current rates for the configured target currencies +$driver->get(Currency::DKK); // override base currency for this call +``` -> !**Note:** Each driver sets its own default base currency. [FixerIO](https://fixer.io) uses `EUR` as base currency -> while [CurrencyLayer](https://currencylayer.com) uses `USD`. +### Historical rates -Most services only allow you to change the base currency in their paid plans. The driver will throw a -`Otherguy\Currency\Exceptions\ApiException` if your current plan does not allow changing the base currency. +Dates must be `DateTimeInterface` (or `null`). ```php -$currency->source(Otherguy\Currency\Symbol::USD); -$currency->from(Otherguy\Currency\Symbol::USD); +use DateTimeImmutable; + +$driver->date(new DateTimeImmutable('2010-01-01'))->historical(); +$driver->historical(new DateTimeImmutable('2018-07-01')); ``` -### Set Return Currencies +### Convert amount -You can use either `to()` or `symbols()` to set the return currencies. The methods are identical. Pass a single currency -or an array of currency symbols to either of these methods. +```php +$driver->convert(10.00, Currency::USD, Currency::THB); +$driver->convert(122.50, Currency::NPR, Currency::EUR, new DateTimeImmutable('2019-01-01')); +``` + +For providers without a native `/convert` endpoint (e.g. Frankfurter), the driver fetches the rate via `get()` / `historical()` and multiplies client-side using `BigDecimal`. + +CurrencyAPI and fastFOREX both expose native latest conversion endpoints. For dated conversions, their drivers fetch historical rates and return a `ConversionResult` for the requested pair. + +### Fluent chain -> !**Note:** Pass an empty array to return all currency symbols supported by this driver. This is the default if you -> don't call the method at all. - ```php -$currency->to(Otherguy\Currency\Symbol::BTC); -$currency->symbols([Otherguy\Currency\Symbol::BTC, Otherguy\Currency\Symbol::EUR, Otherguy\Currency\Symbol::USD]); +DriverFactory::make('fixerio')->from(Currency::USD)->to(Currency::EUR)->get(); +DriverFactory::make('fixerio')->from(Currency::USD)->to(Currency::NPR)->date(new DateTimeImmutable('2013-03-02'))->historical(); +DriverFactory::make('fixerio')->from(Currency::USD)->to(Currency::NPR)->amount(12.10)->convert(); ``` -### Latest Rates +### `ConversionResult` -This retrieves the most recent exchange rates and returns a [`ConversionResult`](#conversion-result) object. +`get()` and `historical()` return a [`ConversionResult`](src/Results/ConversionResult.php). Rates are stored as `BigDecimal` and rebasing is lossless (default scale: 8 decimals). ```php -$currency->get(); // Get latest rates for selected symbols, using set base currency -$currency->get('DKK'); // Get latest rates for selected symbols, using DKK as base currency +use Brick\Math\BigDecimal; + +$result = DriverFactory::make('frankfurter') + ->from(Currency::USD) + ->to([Currency::EUR, Currency::GBP]) + ->get(); + +$result->all(); // ['USD' => BigDecimal '1', 'EUR' => BigDecimal '0.89', 'GBP' => BigDecimal '0.79'] +$result->allAsFloats(); // legacy float view +$result->getBaseCurrency(); // 'USD' +$result->getDate(); // '2026-04-25' +$result->rate(Currency::EUR); // BigDecimal '0.89' +$result->rateAsFloat(Currency::EUR);// 0.89 + +$result->convert(5.0, Currency::EUR, Currency::USD); // BigDecimal '5.618...' + +$rebased = $result->setBaseCurrency(Currency::EUR); +$rebased->getBaseCurrency(); // 'EUR' +$rebased->originalBaseCurrency; // 'USD' — readonly, never mutated ``` -### Historical Rates +`rate()` on a code that wasn't fetched throws `Otherguy\Currency\Exceptions\CurrencyException`. To convert between two arbitrary currencies, request both in the original `get()` / `historical()` call. -To retrieve historical exchange rates, use the `historical()` method. Note that you need to specify a date either as a -method parameter or by using the `date()` methods. See [Fluent Interface](#fluent-interface) for more information. +## Registering custom drivers + +The factory is instance-based. Bring your own driver class (extending `BaseCurrencyDriver`) and register it: + +```php +use Otherguy\Currency\DriverFactory; + +$factory = new DriverFactory(); +$factory->register('mybank', \Acme\MyBankDriver::class); + +$driver = $factory->build('mybank'); +``` + +The static `DriverFactory::make($name)` continues to work via a process-wide default factory — `DriverFactory::setDefault($factory)` lets you swap it for tests. + +### Adding a new driver + +A driver is the bridge between this library's fluent interface and a specific upstream rate provider. Every driver implements [`CurrencyDriverContract`](src/Drivers/CurrencyDriverContract.php) by extending [`BaseCurrencyDriver`](src/Drivers/BaseCurrencyDriver.php). + +The base class supplies: + +- All fluent setters (`source`, `from`, `currencies`, `to`, `amount`, `date`, `config`, `accessKey`, `secure`). +- A PSR-18 / PSR-17 HTTP layer in `apiRequest()` that builds the URI, merges `$httpParams` with per-call params, decodes JSON with `JSON_THROW_ON_ERROR`, and wraps every failure mode in `ApiException`. + +You only need to: + +1. Set the right defaults for `$apiURL`, `$protocol`, and `$baseCurrency`. +2. Implement `get()`, `historical()`, and `convert()`. +3. Override `apiRequest()` only if the provider's successful HTTP response can still carry an error envelope, such as `success: false`. + +### Driver skeleton + +```php +currencies($forCurrency); + } + + $response = $this->apiRequest('latest', [ + 'base' => $this->getBaseCurrency(), + 'symbols' => implode(',', $this->getSymbols()), + ]); + + return new ConversionResult( + (string) $response['base'], + (string) $response['date'], + $response['rates'], + ); + } + + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + if ($this->getDate() === null) { + throw new ApiException('Date is required for historical().'); + } + + $response = $this->apiRequest('history/' . $this->getDate(), [ + 'base' => $this->getBaseCurrency(), + ]); + + return new ConversionResult( + (string) $response['base'], + (string) $response['date'], + $response['rates'], + ); + } + + public function convert( + ?float $amount = null, + string|Currency|null $fromCurrency = null, + string|Currency|null $toCurrency = null, + ?DateTimeInterface $date = null, + ): ConversionResult { + if ($amount !== null) { + $this->amount = $amount; + } + if ($fromCurrency !== null) { + $this->source($fromCurrency); + } + if ($toCurrency !== null) { + $this->to($toCurrency); + } + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($this->amount === null) { + throw new ApiException('An amount is required for convert().'); + } + if ($this->currencies === []) { + throw new ApiException('A target currency is required for convert().'); + } + + $target = $this->getSymbols()[0]; + $response = $this->apiRequest('convert', [ + 'from' => $this->getBaseCurrency(), + 'to' => $target, + 'amount' => $this->amount, + ]); + + return new ConversionResult( + $this->getBaseCurrency(), + isset($response['date']) ? (string) $response['date'] : null, + [$target => $response['result']], + ); + } +} +``` + +For providers without a native conversion endpoint, fetch a rate through `get()` / `historical()` and multiply client-side with `BigDecimal`; [`Frankfurter`](src/Drivers/Frankfurter.php) is the compact example. + +### Driver authentication + +`accessKey()` defaults to writing `access_key=...` into `$httpParams`. If your provider uses a different parameter name, override it: ```php -$currency->date('2010-01-01')->historical(); -$currency->historical('2018-07-01'); +#[\Override] +public function accessKey(string $accessKey): static +{ + return $this->config('apikey', $accessKey); +} ``` -### Convert Amount +For header authentication, write to `$httpHeaders`: -Use the `convert()` method to convert amounts between currencies. +```php +#[\Override] +public function accessKey(string $accessKey): static +{ + $this->httpHeaders['apikey'] = $accessKey; -> !**Note:** Most API providers don't allow access to this method using your free account. You can still use the -> [Latest Rates](#latest-rates) or [Historical Rates](#historical-rates) endpoints and perform calculations or conversions -> on the [`ConversionResult`](#conversion-result) object. + return $this; +} +``` + +If the provider has no keys, throw to make misuse loud: ```php -$currency->convert(10.00, 'USD', 'THB'); // Convert 10 USD to THB -$currency->convert(122.50, 'NPR', 'EUR', '2019-01-01'); // Convert 122.50 NPR to EUR using the rates from January 1st, 2019 +#[\Override] +public function accessKey(string $accessKey): static +{ + throw new ApiException('MyProvider does not require an API key.'); +} ``` -### Fluent Interface +### Provider-specific error envelopes -Most methods can be used with a _fluent interface_, allowing you to chain method calls for more readable code: +Many providers return HTTP 200 with an error body. Override `apiRequest()` to translate those into `ApiException` before the value reaches `get()` / `historical()` / `convert()`: ```php -// Namespaces are omitted for readability! -DriverFactory::make('driver')->from(Symbol::USD)->to(Symbol::EUR)->get(); -DriverFactory::make('driver')->from(Symbol::USD)->to(Symbol::NPR)->date('2013-03-02')->historical(); -DriverFactory::make('driver')->from(Symbol::USD)->to(Symbol::NPR)->amount(12.10)->convert(); +#[\Override] +protected function apiRequest(string $endpoint, array $params = []): array +{ + $response = parent::apiRequest($endpoint, $params); + + if (($response['success'] ?? null) !== true) { + $info = (string) ($response['error']['info'] ?? 'Unknown API error.'); + throw new ApiException($info); + } + + return $response; +} ``` -### Conversion Result +### First-party driver registration + +For first-party drivers shipped with this package, add the class to the built-in map in [`DriverFactory`](src/DriverFactory.php): -The [`get()`](#latest-rates) and [`historical()`](#historical-rates) endpoints return a -[`ConversionResult`](src/Results/ConversionResult.php) object. This object allows you to perform calculations and -conversions easily. +```php +public function __construct(?array $drivers = null) +{ + $this->drivers = $drivers ?? [ + // ... + 'myprovider' => MyProvider::class, + ]; +} +``` -> !**Note:** Even though free accounts of most providers do not allow you to change the base currency, you can still -> use the `ConversionResult` object to change the base currency later. This might not be as accurate as changing the -> base currency directly, though. +For third-party drivers, use runtime registration as shown above. `register()` returns `$this`, `unregister(string $name)` removes a driver, and `build()` accepts optional PSR-18 + PSR-17 collaborators. If those collaborators are omitted, the factory tries to auto-discover Guzzle. -> !**Note:** To convert between two currencies, you need to request both of them in your initial [`get()`](#latest-rates) -> or [`historical()`](#historical-rates) request. You can not convert between currencies that have not been fetched! +### Driver tests -See the following code for some examples of what you can do with the `ConversionResult` object. +Driver tests live under `tests/Drivers/`. Use [`tests/Support/DriverHarness.php`](tests/Support/DriverHarness.php) to wire up an in-process PSR-18 mock: ```php -$result = DriverFactory::make('driver')->from(Symbol::USD)->to([Symbol::EUR, Symbol::GBP])->get(); +use Otherguy\Currency\Currency; +use Otherguy\Currency\Tests\Support\DriverHarness; +use Otherguy\Currency\Tests\Support\JsonResponse; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +class MyProviderTest extends TestCase +{ + private DriverHarness $harness; + + protected function setUp(): void + { + $this->harness = new DriverHarness(); + } + + #[Test] + public function get_parses_provider_envelope(): void + { + $this->harness->http->enqueue(JsonResponse::ok(json_encode([ + 'base' => 'USD', + 'date' => '2026-04-01', + 'rates' => ['EUR' => 0.92], + ], JSON_THROW_ON_ERROR))); + + $result = $this->harness->make('myprovider') + ->accessKey('test-key') + ->from(Currency::USD) + ->to(Currency::EUR) + ->get(); + + $this->assertSame('0.92', (string) $result->rate(Currency::EUR)); + + $request = $this->harness->http->lastRequest(); + $this->assertNotNull($request); + $this->assertStringContainsString('apikey=test-key', $request->getUri()->getQuery()); + } +} +``` + +`DriverHarness` instantiates a fresh `MockHttpClient` on each test. Use `enqueue()` to queue responses, `lastRequest()` to assert URI/query/headers, and `sentRequests()` for multi-request flows. -// [ 'USD' => 1.00, 'EUR' => 0.89, 'GBP' => 0.79 ] -$result->all(); +### Driver checklist -// 'USD' -$result->getBaseCurrency(); +- [ ] `declare(strict_types=1)` and `Override` attributes where you override. +- [ ] `$apiURL` does not include the `https://` prefix; `BaseCurrencyDriver` adds the protocol. +- [ ] `get()`, `historical()`, and `convert()` return `ConversionResult`, not arrays. +- [ ] Error envelopes are wrapped in `ApiException` so callers see one consistent failure type. +- [ ] PHPStan is clean at `level: max` (`composer analyse`). +- [ ] Tests cover happy path, error envelope, and any `accessKey()` quirks. +- [ ] First-party drivers are registered in `DriverFactory` and listed in the Supported APIs table. -// '2019-06-11' -$result->getDate(); +For real examples, browse the existing drivers. They range from thin happy-path code in [`Frankfurter`](src/Drivers/Frankfurter.php), to header authentication in [`CurrencyApi`](src/Drivers/CurrencyApi.php), to envelope translation in [`FixerIo`](src/Drivers/FixerIo.php), [`CurrencyLayer`](src/Drivers/CurrencyLayer.php), and [`ExchangeRatesApi`](src/Drivers/ExchangeRatesApi.php). -// 0.89 -$result->rate(Symbol::EUR); +## Testing -// CurrencyException("No conversion result for BTC!"); -$result->rate(Symbol::BTC); +The library exposes `Otherguy\Currency\Drivers\MockCurrencyDriver` for consumers writing tests without a network. Seed it with rates and use it like any other driver: -// 5.618 -$result->convert(5.0, Symbol::EUR, Symbol::USD); +```php +use Otherguy\Currency\Drivers\MockCurrencyDriver; -// [ 'USD' => 1.13, 'EUR' => 1.00, 'GBP' => 0.89 ] -$result->setBaseCurrency(Symbol::EUR)->all(); +$driver = (new MockCurrencyDriver(/* PSR-18 + factory */)) + ->withRates(['EUR' => '0.92', 'GBP' => '0.79']); -// 1.12 -$result->setBaseCurrency(Symbol::GBP)->rate(Symbol::EUR); +$driver->get()->rate('EUR'); // BigDecimal '0.92' ``` -## Contributing 🚧 +For testing this library itself, see `tests/Support/MockHttpClient.php` — a tiny in-process PSR-18 double that records sent requests and replays queued responses. CONTRIBUTING.md walks through it. + +## Project commands + +| Command | What it does | +|-------------------------------|---------------------------------------| +| `composer test` | Run the test suite | +| `composer test:coverage` | Run with coverage (requires Xdebug) | +| `composer lint` | Pint code-style check (read-only) | +| `composer lint:fix` | Pint auto-fix | +| `composer analyse` | PHPStan at level max | +| `composer rector` | Rector dry-run | +| `composer rector:fix` | Rector apply | +| `composer check` | All of the above, in order | + +## Contributing + +Pull requests are welcome — please run `composer check` before opening one. Coverage target is ≥ 98% on `src/`. See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the full guide. + +## License -[Pull Requests](https://github.com/otherguy/php-currency-api/pulls) are more than welcome! I'm striving for 100% test -coverage for this repository so please make sure to add tests for your code. +[MIT](LICENSE.md). diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000..2bb3135 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,69 @@ + +# Upgrading from 1.x + +| Area | 1.x | 2.0 | +| --- | --- | --- | +| PHP | `>=7.3` | `^8.3` | +| HTTP client | Hard-wired Guzzle | Any PSR-18 client + PSR-17 factory | +| Currency catalog | `Symbol` constants | `Currency` backed enum (`Symbol` remains as a deprecation shim) | +| Rate type | `float` | `Brick\Math\BigDecimal` | +| Dates | strings, timestamps, intervals, date objects | `?DateTimeInterface` | +| Default protocol | `http` | `https` | + +Required changes: + +1. Bump composer constraints: + + ```bash + composer require otherguy/php-currency-api:^2.0 + composer require guzzlehttp/guzzle:^7.9 http-interop/http-factory-guzzle:^1.2 + ``` + + If you do not want Guzzle, install any PSR-18 client and PSR-17 factory and pass them to `DriverFactory::build()`. + +2. Wrap date strings in `DateTimeImmutable`: + + ```diff + - $driver->historical('2018-07-01'); + + $driver->historical(new DateTimeImmutable('2018-07-01')); + + - $driver->convert(122.50, 'NPR', 'EUR', '2019-01-01'); + + $driver->convert(122.50, 'NPR', 'EUR', new DateTimeImmutable('2019-01-01')); + ``` + +3. Treat rates as `BigDecimal`: + + ```diff + - $rate = $result->rate('EUR'); // float + - $total = $rate * 100; + + $rate = $result->rate('EUR'); // BigDecimal + + $total = $rate->multipliedBy(100); + ``` + + For legacy reporting or JSON output, use `rateAsFloat()` or `allAsFloats()`. + +4. Prefer `Currency` over `Symbol` in new code: + + ```diff + - use Otherguy\Currency\Symbol; + - $driver->to(Symbol::EUR); + + use Otherguy\Currency\Currency; + + $driver->to(Currency::EUR); + ``` + + Plain strings such as `'USD'` still work everywhere a currency is accepted, so this can be incremental. + +5. Update direct driver construction if you used it: + + ```diff + - $driver = new FixerIo(new GuzzleHttp\Client()); + + $driver = (new DriverFactory())->build('fixerio', new GuzzleHttp\Client(), new Http\Factory\Guzzle\RequestFactory()); + ``` + + `DriverFactory::make('fixerio')` still works and auto-discovers Guzzle. + +Provider notes: + +- `exchangeratesapi` now targets APILayer's `api.apilayer.com/exchangerates_data` API. `accessKey()` now sends the required `apikey` parameter, and `convert()` is implemented. +- 2.0 adds `frankfurter`, `currencyapi`, and `fastforex` as built-in driver identifiers. +- `apiRequest()` is now protected. Call `get()`, `historical()`, or `convert()` from consumers, or extend the driver for custom behavior. diff --git a/composer.json b/composer.json index 1a1339c..e39bc63 100644 --- a/composer.json +++ b/composer.json @@ -1,64 +1,81 @@ { - "name": "otherguy/php-currency-api", - "description": "A PHP API Wrapper to offer a unified programming interface for popular Currency Rate APIs.", - "keywords": [ - "currency", - "currency rates", - "exchange rates", - "currency conversion", + "name": "otherguy/php-currency-api", + "description": "A PHP API Wrapper to offer a unified programming interface for popular Currency Rate APIs.", + "keywords": [ "currency", - "currency-exchange-rates", - "composer-package", - "php-library", - "currency-converter-php", - "currency-api", + "currency-rates", "exchange-rates", - "currency-rate-apis", - "fluent-interface", - "api-wrapper", - "currency-converter", "currency-conversion", - "currency-exchange", + "currency-converter", + "currency-api", "exchange-rate-api", - "exchange-rates-api", - "php-package", - "php8", - "php74", - "php73" - ], - "type": "library", - "license": "MIT", - "authors": [ - { - "name": "Alexander Graf", - "email": "alex@otherguy.io" - } - ], - "require": { - "php": ">=7.3", + "fluent-interface", + "psr-18", + "php8" + ], + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Alexander Graf", + "email": "alex@otherguy.io" + } + ], + "require": { + "php": "^8.3", "ext-json": "*", - "guzzlehttp/guzzle": "^7.2" - }, - "require-dev": { - "nikic/php-parser": "^4.10", - "php-coveralls/php-coveralls": "^2.4", - "phpunit/php-code-coverage": "^9.2", - "phpunit/phpunit": "^9.4" - }, - "suggest": { - "ext-curl": "Faster HTTP requests" - }, - "autoload": { - "psr-4": { - "Otherguy\\Currency\\": "src/" - } - }, - "autoload-dev": { - "psr-4": { - "Currency\\Tests\\": "tests/" - } - }, - "config": { - "sort-packages": true - } + "brick/math": "^0.12", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "require-dev": { + "guzzlehttp/guzzle": "^7.9", + "http-interop/http-factory-guzzle": "^1.2", + "laravel/pint": "^1.18", + "php-coveralls/php-coveralls": "^2.7", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^12.0", + "rector/rector": "^2.0" + }, + "suggest": { + "guzzlehttp/guzzle": "PSR-18 HTTP client (^7.9), still the de-facto choice and what the test suite uses", + "symfony/http-client": "Alternative PSR-18 client (^7.0); pair with nyholm/psr7 for PSR-17 factories", + "nyholm/psr7": "Lightweight PSR-7/PSR-17 implementation, recommended with symfony/http-client" + }, + "autoload": { + "psr-4": { + "Otherguy\\Currency\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Otherguy\\Currency\\Tests\\": "tests/" + } + }, + "scripts": { + "test": "phpunit --no-coverage", + "test:coverage": "XDEBUG_MODE=coverage phpunit --coverage-text", + "lint": "pint --test", + "lint:fix": "pint", + "analyse": "phpstan analyse", + "rector": "rector --dry-run", + "rector:fix": "rector", + "check": [ + "@lint", + "@analyse", + "@rector", + "@test" + ] + }, + "config": { + "platform": { + "php": "8.3.30" + }, + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true + } + } } diff --git a/composer.lock b/composer.lock index e67206d..78c327f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,26 +4,248 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "271a92e359371033df60fa8d63e37268", + "content-hash": "94f4767d77d28e0fc91f1404d6596ff0", "packages": [ + { + "name": "brick/math", + "version": "0.12.3", + "source": { + "type": "git", + "url": "https://github.com/brick/math.git", + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/brick/math/zipball/866551da34e9a618e64a819ee1e01c20d8a588ba", + "reference": "866551da34e9a618e64a819ee1e01c20d8a588ba", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^10.1", + "vimeo/psalm": "6.8.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Brick\\Math\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Arbitrary-precision arithmetic library", + "keywords": [ + "Arbitrary-precision", + "BigInteger", + "BigRational", + "arithmetic", + "bigdecimal", + "bignum", + "bignumber", + "brick", + "decimal", + "integer", + "math", + "mathematics", + "rational" + ], + "support": { + "issues": "https://github.com/brick/math/issues", + "source": "https://github.com/brick/math/tree/0.12.3" + }, + "funding": [ + { + "url": "https://github.com/BenMorel", + "type": "github" + } + ], + "time": "2025-02-28T13:11:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + } + ], + "packages-dev": [ { "name": "guzzlehttp/guzzle", - "version": "7.8.1", + "version": "7.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "41042bc7ab002487b876a0683fc8dce04ddce104" + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/41042bc7ab002487b876a0683fc8dce04ddce104", - "reference": "41042bc7ab002487b876a0683fc8dce04ddce104", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.1", - "guzzlehttp/psr7": "^1.9.1 || ^2.5.1", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -34,9 +256,9 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", - "php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999", + "guzzle/client-integration-tests": "3.0.2", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -112,6 +334,10 @@ "rest", "web service" ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, "funding": [ { "url": "https://github.com/GrahamCampbell", @@ -126,20 +352,20 @@ "type": "tidelift" } ], - "time": "2023-12-03T20:35:24+00:00" + "time": "2025-08-23T22:36:01+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.0.2", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223" + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/bbff78d96034045e58e13dedd6ad91b5d1253223", - "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { @@ -147,7 +373,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "type": "library", "extra": { @@ -191,6 +417,10 @@ "keywords": [ "promise" ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, "funding": [ { "url": "https://github.com/GrahamCampbell", @@ -205,20 +435,20 @@ "type": "tidelift" } ], - "time": "2023-12-03T20:19:20+00:00" + "time": "2025-08-22T14:34:08+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.6.2", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/45b30f99ac27b5ca93cb4831afe16285f57b8221", - "reference": "45b30f99ac27b5ca93cb4831afe16285f57b8221", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -233,8 +463,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "^0.9", - "phpunit/phpunit": "^8.5.36 || ^9.6.15" + "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -287,260 +518,76 @@ "homepage": "https://github.com/Tobion" }, { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" - } - ], - "description": "PSR-7 message implementation that also provides common utility methods", - "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" - ], - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", - "type": "tidelift" - } - ], - "time": "2023-12-03T20:05:35+00:00" - }, - { - "name": "psr/http-client", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], - "time": "2023-09-23T14:17:50+00:00" - }, - { - "name": "psr/http-factory", - "version": "1.0.2", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "e616d01114759c4c489f93b099585439f795fe35" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35", - "reference": "e616d01114759c4c489f93b099585439f795fe35", - "shasum": "" - }, - "require": { - "php": ">=7.0.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "time": "2023-04-10T20:10:41+00:00" - }, - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "time": "2023-04-04T09:54:51+00:00" - }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } ], - "authors": [ + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.9.0" + }, + "funding": [ { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" } ], - "description": "A polyfill for getallheaders.", - "time": "2019-03-08T08:55:37+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { - "name": "symfony/deprecation-contracts", - "version": "v2.5.2", + "name": "http-interop/http-factory-guzzle", + "version": "1.2.1", "source": { "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66" + "url": "https://github.com/http-interop/http-factory-guzzle.git", + "reference": "c2c859ceb05c3f42e710b60555f4c35b6a4a3995" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66", - "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66", + "url": "https://api.github.com/repos/http-interop/http-factory-guzzle/zipball/c2c859ceb05c3f42e710b60555f4c35b6a4a3995", + "reference": "c2c859ceb05c3f42e710b60555f4c35b6a4a3995", "shasum": "" }, "require": { - "php": ">=7.1" + "guzzlehttp/psr7": "^1.7||^2.0", + "php": ">=7.3", + "psr/http-factory": "^1.0" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-main": "2.5-dev" - }, - "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" - } + "provide": { + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "http-interop/http-factory-tests": "^0.9", + "phpunit/phpunit": "^9.5" + }, + "suggest": { + "guzzlehttp/psr7": "Includes an HTTP factory starting in version 2.0" }, + "type": "library", "autoload": { - "files": [ - "function.php" - ] + "psr-4": { + "Http\\Factory\\Guzzle\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -548,65 +595,63 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" } ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } + "description": "An HTTP Factory using Guzzle PSR7", + "keywords": [ + "factory", + "http", + "psr-17", + "psr-7" ], - "time": "2022-01-02T09:53:40+00:00" - } - ], - "packages-dev": [ + "support": { + "issues": "https://github.com/http-interop/http-factory-guzzle/issues", + "source": "https://github.com/http-interop/http-factory-guzzle/tree/1.2.1" + }, + "time": "2025-12-15T11:28:16+00:00" + }, { - "name": "doctrine/instantiator", - "version": "1.5.0", + "name": "laravel/pint", + "version": "v1.29.1", "source": { "type": "git", - "url": "https://github.com/doctrine/instantiator.git", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b" + "url": "https://github.com/laravel/pint.git", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/instantiator/zipball/0a0fa9780f5d4e507415a065172d26a98d02047b", - "reference": "0a0fa9780f5d4e507415a065172d26a98d02047b", + "url": "https://api.github.com/repos/laravel/pint/zipball/0770e9b7fafd50d4586881d456d6eb41c9247a80", + "reference": "0770e9b7fafd50d4586881d456d6eb41c9247a80", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.2.0" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^11", - "ext-pdo": "*", - "ext-phar": "*", - "phpbench/phpbench": "^0.16 || ^1", - "phpstan/phpstan": "^1.4", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "vimeo/psalm": "^4.30 || ^5.4" + "friendsofphp/php-cs-fixer": "^3.95.1", + "illuminate/view": "^12.56.0", + "larastan/larastan": "^3.9.6", + "laravel-zero/framework": "^12.1.0", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.3" }, - "type": "library", + "bin": [ + "builds/pint" + ], + "type": "project", "autoload": { "psr-4": { - "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" } }, "notification-url": "https://packagist.org/downloads/", @@ -615,45 +660,38 @@ ], "authors": [ { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com", - "homepage": "https://ocramius.github.io/" + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" } ], - "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", - "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", "keywords": [ - "constructor", - "instantiate" - ], - "funding": [ - { - "url": "https://www.doctrine-project.org/sponsorship.html", - "type": "custom" - }, - { - "url": "https://www.patreon.com/phpdoctrine", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", - "type": "tidelift" - } + "dev", + "format", + "formatter", + "lint", + "linter", + "php" ], - "time": "2022-12-30T00:15:36+00:00" + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-04-20T15:26:14+00:00" }, { "name": "myclabs/deep-copy", - "version": "1.11.1", + "version": "1.13.4", "source": { "type": "git", "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c" + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", - "reference": "7284c22080590fb39f2ffa3e9057f10a4ddd0e0c", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", "shasum": "" }, "require": { @@ -661,11 +699,12 @@ }, "conflict": { "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3,<3.2.2" + "doctrine/common": "<2.13.3 || >=3 <3.2.2" }, "require-dev": { "doctrine/collections": "^1.6.8", "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" }, "type": "library", @@ -689,35 +728,41 @@ "object", "object graph" ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, "funding": [ { "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", "type": "tidelift" } ], - "time": "2023-03-08T13:26:56+00:00" + "time": "2025-08-01T08:46:24+00:00" }, { "name": "nikic/php-parser", - "version": "v4.17.1", + "version": "v5.7.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d" + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", - "reference": "a6303e50c90c355c7eeee2c4a8b27fe8dc8fef1d", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-json": "*", "ext-tokenizer": "*", - "php": ">=7.0" + "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" @@ -725,7 +770,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.9-dev" + "dev-master": "5.x-dev" } }, "autoload": { @@ -747,24 +792,29 @@ "parser", "php" ], - "time": "2023-08-13T19:53:39+00:00" + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" }, { "name": "phar-io/manifest", - "version": "2.0.3", + "version": "2.0.4", "source": { "type": "git", "url": "https://github.com/phar-io/manifest.git", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53" + "reference": "54750ef60c58e43759730615a392c31c80e23176" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/97803eca37d319dfa7826cc2437fc020857acb53", - "reference": "97803eca37d319dfa7826cc2437fc020857acb53", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", "shasum": "" }, "require": { "ext-dom": "*", + "ext-libxml": "*", "ext-phar": "*", "ext-xmlwriter": "*", "phar-io/version": "^3.0.1", @@ -803,7 +853,17 @@ } ], "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", - "time": "2021-07-20T11:28:43+00:00" + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" }, { "name": "phar-io/version", @@ -850,36 +910,40 @@ } ], "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, "time": "2022-02-21T01:04:05+00:00" }, { "name": "php-coveralls/php-coveralls", - "version": "v2.7.0", + "version": "v2.9.1", "source": { "type": "git", "url": "https://github.com/php-coveralls/php-coveralls.git", - "reference": "b36fa4394e519dafaddc04ae03976bc65a25ba15" + "reference": "916bdb118597f61ce6715fb738ab8f234b89a2cb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-coveralls/php-coveralls/zipball/b36fa4394e519dafaddc04ae03976bc65a25ba15", - "reference": "b36fa4394e519dafaddc04ae03976bc65a25ba15", + "url": "https://api.github.com/repos/php-coveralls/php-coveralls/zipball/916bdb118597f61ce6715fb738ab8f234b89a2cb", + "reference": "916bdb118597f61ce6715fb738ab8f234b89a2cb", "shasum": "" }, "require": { "ext-json": "*", "ext-simplexml": "*", "guzzlehttp/guzzle": "^6.0 || ^7.0", - "php": "^7.0 || ^8.0", - "psr/log": "^1.0 || ^2.0", - "symfony/config": "^2.1 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", - "symfony/console": "^2.1 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", - "symfony/stopwatch": "^2.0 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0", - "symfony/yaml": "^2.0.5 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0" + "php": "^7.4 || ^8.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/config": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/console": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/stopwatch": "^5.4 || ^6.4 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.4 || ^7.0 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^4.8.35 || ^5.4.3 || ^6.0 || ^7.0 || >=8.0 <8.5.29 || >=9.0 <9.5.23", - "sanmai/phpunit-legacy-adapter": "^6.1 || ^8.0" + "phpspec/prophecy-phpunit": "^2.4", + "phpunit/phpunit": "^9.6.29 || ^10.5.58 || ^11.5.43" }, "suggest": { "symfony/http-kernel": "Allows Symfony integration" @@ -929,39 +993,150 @@ "github", "test" ], - "time": "2023-11-22T10:21:01+00:00" + "support": { + "issues": "https://github.com/php-coveralls/php-coveralls/issues", + "source": "https://github.com/php-coveralls/php-coveralls/tree/v2.9.1" + }, + "time": "2025-12-18T13:08:37+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.1.51", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc3b523c45e714c70de2ac5113b958223b55dc59", + "reference": "dc3b523c45e714c70de2ac5113b958223b55dc59", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-04-21T18:22:01+00:00" + }, + { + "name": "phpstan/phpstan-phpunit", + "version": "2.0.16", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-phpunit.git", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "reference": "6ab598e1bc106e6827fd346ae4a12b4a5d634c32", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.32" + }, + "conflict": { + "phpunit/phpunit": "<7.0" + }, + "require-dev": { + "nikic/php-parser": "^5", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPUnit extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-phpunit/issues", + "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.16" + }, + "time": "2026-02-14T09:05:21+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.29", + "version": "12.5.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76" + "reference": "876099a072646c7745f673d7aeab5382c4439691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/6a3a87ac2bbe33b25042753df8195ba4aa534c76", - "reference": "6a3a87ac2bbe33b25042753df8195ba4aa534c76", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/876099a072646c7745f673d7aeab5382c4439691", + "reference": "876099a072646c7745f673d7aeab5382c4439691", "shasum": "" }, "require": { "ext-dom": "*", "ext-libxml": "*", "ext-xmlwriter": "*", - "nikic/php-parser": "^4.15", - "php": ">=7.3", - "phpunit/php-file-iterator": "^3.0.3", - "phpunit/php-text-template": "^2.0.2", - "sebastian/code-unit-reverse-lookup": "^2.0.2", - "sebastian/complexity": "^2.0", - "sebastian/environment": "^5.1.2", - "sebastian/lines-of-code": "^1.0.3", - "sebastian/version": "^3.0.1", - "theseer/tokenizer": "^1.2.0" + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.0.3", + "sebastian/lines-of-code": "^4.0", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.5.1" }, "suggest": { "ext-pcov": "PHP extension that provides line coverage", @@ -970,7 +1145,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.2-dev" + "dev-main": "12.5.x-dev" } }, "autoload": { @@ -996,38 +1171,55 @@ "testing", "xunit" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.6" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" } ], - "time": "2023-09-19T04:57:46+00:00" + "time": "2026-04-15T08:23:17+00:00" }, { "name": "phpunit/php-file-iterator", - "version": "3.0.6", + "version": "6.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf" + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", - "reference": "cf1c2e7c203ac650e352f4cc675a7021e7d1b3cf", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -1052,34 +1244,51 @@ "filesystem", "iterator" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" } ], - "time": "2021-12-02T12:48:52+00:00" + "time": "2026-02-02T14:04:18+00:00" }, { "name": "phpunit/php-invoker", - "version": "3.1.1", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67" + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67", - "reference": "5a10147d0aaf65b58940a0b72f71c9ac0423cc67", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { "ext-pcntl": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-pcntl": "*" @@ -1087,7 +1296,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "3.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -1111,38 +1320,43 @@ "keywords": [ "process" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2020-09-28T05:58:55+00:00" + "time": "2025-02-07T04:58:58+00:00" }, { "name": "phpunit/php-text-template", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28" + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", - "reference": "5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1166,38 +1380,43 @@ "keywords": [ "template" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2020-10-26T05:33:50+00:00" + "time": "2025-02-07T04:59:16+00:00" }, { "name": "phpunit/php-timer", - "version": "5.0.3", + "version": "8.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2" + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", - "reference": "5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -1221,60 +1440,60 @@ "keywords": [ "timer" ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2020-10-26T13:16:10+00:00" + "time": "2025-02-07T04:59:38+00:00" }, { "name": "phpunit/phpunit", - "version": "9.6.15", + "version": "12.5.23", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1" + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/05017b80304e0eb3f31d90194a563fd53a6021f1", - "reference": "05017b80304e0eb3f31d90194a563fd53a6021f1", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", + "reference": "c54fcf3d6bcb6e96ac2f7e40097dc37b5f139969", "shasum": "" }, "require": { - "doctrine/instantiator": "^1.3.1 || ^2", "ext-dom": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", "ext-xml": "*", "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.10.1", - "phar-io/manifest": "^2.0.3", - "phar-io/version": "^3.0.2", - "php": ">=7.3", - "phpunit/php-code-coverage": "^9.2.28", - "phpunit/php-file-iterator": "^3.0.5", - "phpunit/php-invoker": "^3.1.1", - "phpunit/php-text-template": "^2.0.3", - "phpunit/php-timer": "^5.0.2", - "sebastian/cli-parser": "^1.0.1", - "sebastian/code-unit": "^1.0.6", - "sebastian/comparator": "^4.0.8", - "sebastian/diff": "^4.0.3", - "sebastian/environment": "^5.1.3", - "sebastian/exporter": "^4.0.5", - "sebastian/global-state": "^5.0.1", - "sebastian/object-enumerator": "^4.0.3", - "sebastian/resource-operations": "^3.0.3", - "sebastian/type": "^3.2", - "sebastian/version": "^3.0.2" - }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.6", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.0", + "sebastian/comparator": "^7.1.6", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.0", + "sebastian/exporter": "^7.0.2", + "sebastian/global-state": "^8.0.2", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.3", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" }, "bin": [ "phpunit" @@ -1282,7 +1501,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "9.6-dev" + "dev-main": "12.5-dev" } }, "autoload": { @@ -1311,40 +1530,42 @@ "testing", "xunit" ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.23" + }, "funding": [ { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" + "url": "https://phpunit.de/sponsoring.html", + "type": "other" } ], - "time": "2023-12-01T16:55:19+00:00" + "time": "2026-04-18T06:12:49+00:00" }, { "name": "psr/container", - "version": "1.1.1", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/container.git", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf" + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/8622567409010282b7aeebe4bb841fe98b58dcaf", - "reference": "8622567409010282b7aeebe4bb841fe98b58dcaf", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { - "php": ">=7.2.0" + "php": ">=7.4.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, "autoload": { "psr-4": { "Psr\\Container\\": "src/" @@ -1369,34 +1590,38 @@ "container-interop", "psr" ], - "time": "2021-03-05T17:36:06+00:00" + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" }, { "name": "psr/log", - "version": "1.1.4", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "Psr\\Log\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1416,136 +1641,139 @@ "psr", "psr-3" ], - "time": "2021-05-03T11:20:27+00:00" + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" }, { - "name": "sebastian/cli-parser", - "version": "1.0.1", + "name": "ralouphie/getallheaders", + "version": "3.0.3", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2" + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/442e7c7e687e42adc03470c7b668bc4b2402c0b2", - "reference": "442e7c7e687e42adc03470c7b668bc4b2402c0b2", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=5.6" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "files": [ + "src/getallheaders.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } + "MIT" ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", - "funding": [ + "authors": [ { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" } ], - "time": "2020-09-28T06:08:49+00:00" + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" }, { - "name": "sebastian/code-unit", - "version": "1.0.8", + "name": "rector/rector", + "version": "2.4.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120" + "url": "https://github.com/rectorphp/rector.git", + "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/1fc9f64c0927627ef78ba436c9b17d967e68e120", - "reference": "1fc9f64c0927627ef78ba436c9b17d967e68e120", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/e645b6463c6a88ea5b44b17d3387d35a912c7946", + "reference": "e645b6463c6a88ea5b44b17d3387d35a912c7946", "shasum": "" }, "require": { - "php": ">=7.3" + "php": "^7.4|^8.0", + "phpstan/phpstan": "^2.1.48" }, - "require-dev": { - "phpunit/phpunit": "^9.3" + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0-dev" - } + "suggest": { + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" }, + "bin": [ + "bin/rector" + ], + "type": "library", "autoload": { - "classmap": [ - "src/" + "files": [ + "bootstrap.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } + "description": "Instant Upgrade and Automated Refactoring of any PHP code", + "homepage": "https://getrector.com/", + "keywords": [ + "automation", + "dev", + "migration", + "refactoring" ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", + "support": { + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/2.4.2" + }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/tomasvotruba", "type": "github" } ], - "time": "2020-10-26T13:08:54+00:00" + "time": "2026-04-16T13:07:34+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", - "version": "2.0.3", + "name": "sebastian/cli-parser", + "version": "4.2.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5" + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", - "reference": "ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/90f41072d220e5c40df6e8635f5dafba2d9d4d04", + "reference": "90f41072d220e5c40df6e8635f5dafba2d9d4d04", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "4.2-dev" } }, "autoload": { @@ -1560,45 +1788,68 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" } ], - "time": "2020-09-28T05:30:19+00:00" + "time": "2025-09-14T09:36:45+00:00" }, { "name": "sebastian/comparator", - "version": "4.0.8", + "version": "7.1.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a" + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a", - "reference": "fa0f136dd2334583309d32b62544682ee972b51a", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/c769009dee98f494e0edc3fd4f4087501688f11e", + "reference": "c769009dee98f494e0edc3fd4f4087501688f11e", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/diff": "^4.0", - "sebastian/exporter": "^4.0" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.2" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.1-dev" } }, "autoload": { @@ -1635,39 +1886,56 @@ "compare", "equality" ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.6" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" } ], - "time": "2022-09-14T12:41:17+00:00" + "time": "2026-04-14T08:23:15+00:00" }, { "name": "sebastian/complexity", - "version": "2.0.2", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88" + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/739b35e53379900cc9ac327b2147867b8b6efd88", - "reference": "739b35e53379900cc9ac327b2147867b8b6efd88", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", "shasum": "" }, "require": { - "nikic/php-parser": "^4.7", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -1688,39 +1956,44 @@ ], "description": "Library for calculating the complexity of PHP code units", "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2020-10-26T15:52:27+00:00" + "time": "2025-02-07T04:55:25+00:00" }, { "name": "sebastian/diff", - "version": "4.0.5", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131" + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/74be17022044ebaaecfdf0c5cd504fc9cd5a7131", - "reference": "74be17022044ebaaecfdf0c5cd504fc9cd5a7131", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1750,33 +2023,38 @@ "unidiff", "unified diff" ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2023-05-07T05:35:17+00:00" + "time": "2025-02-07T04:55:46+00:00" }, { "name": "sebastian/environment", - "version": "5.1.5", + "version": "8.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed" + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", - "reference": "830c43a844f1f8d5b7a1f6d6076b784454d8b7ed", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/b121608b28a13f721e76ffbbd386d08eff58f3f6", + "reference": "b121608b28a13f721e76ffbbd386d08eff58f3f6", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "suggest": { "ext-posix": "*" @@ -1784,7 +2062,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "5.1-dev" + "dev-main": "8.1-dev" } }, "autoload": { @@ -1803,46 +2081,63 @@ } ], "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "http://www.github.com/sebastianbergmann/environment", + "homepage": "https://github.com/sebastianbergmann/environment", "keywords": [ "Xdebug", "environment", "hhvm" ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" } ], - "time": "2023-02-03T06:03:51+00:00" + "time": "2026-04-15T12:13:01+00:00" }, { "name": "sebastian/exporter", - "version": "4.0.5", + "version": "7.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d" + "reference": "016951ae10980765e4e7aee491eb288c64e505b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", - "reference": "ac230ed27f0f98f597c8a2b6eb7ac563af5e5b9d", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/016951ae10980765e4e7aee491eb288c64e505b7", + "reference": "016951ae10980765e4e7aee491eb288c64e505b7", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/recursion-context": "^4.0" + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "ext-mbstring": "*", - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -1882,44 +2177,58 @@ "export", "exporter" ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.2" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" } ], - "time": "2022-09-14T06:03:37+00:00" + "time": "2025-09-24T06:16:11+00:00" }, { "name": "sebastian/global-state", - "version": "5.0.6", + "version": "8.0.2", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "bde739e7565280bda77be70044ac1047bc007e34" + "reference": "ef1377171613d09edd25b7816f05be8313f9115d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bde739e7565280bda77be70044ac1047bc007e34", - "reference": "bde739e7565280bda77be70044ac1047bc007e34", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/ef1377171613d09edd25b7816f05be8313f9115d", + "reference": "ef1377171613d09edd25b7816f05be8313f9115d", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { "ext-dom": "*", - "phpunit/phpunit": "^9.3" - }, - "suggest": { - "ext-uopz": "*" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "8.0-dev" } }, "autoload": { @@ -1938,43 +2247,60 @@ } ], "description": "Snapshotting of global state", - "homepage": "http://www.github.com/sebastianbergmann/global-state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ "global state" ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.2" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" } ], - "time": "2023-08-02T09:26:13+00:00" + "time": "2025-08-29T11:29:25+00:00" }, { "name": "sebastian/lines-of-code", - "version": "1.0.3", + "version": "4.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc" + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/c1c2e997aa3146983ed888ad08b15470a2e22ecc", - "reference": "c1c2e997aa3146983ed888ad08b15470a2e22ecc", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/97ffee3bcfb5805568d6af7f0f893678fc076d2f", + "reference": "97ffee3bcfb5805568d6af7f0f893678fc076d2f", "shasum": "" }, "require": { - "nikic/php-parser": "^4.6", - "php": ">=7.3" + "nikic/php-parser": "^5.0", + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.0-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -1995,40 +2321,45 @@ ], "description": "Library for counting the lines of code in PHP source code", "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2020-11-28T06:42:11+00:00" + "time": "2025-02-07T04:57:28+00:00" }, { "name": "sebastian/object-enumerator", - "version": "4.0.4", + "version": "7.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71" + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/5c9eeac41b290a3712d88851518825ad78f45c71", - "reference": "5c9eeac41b290a3712d88851518825ad78f45c71", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", "shasum": "" }, "require": { - "php": ">=7.3", - "sebastian/object-reflector": "^2.0", - "sebastian/recursion-context": "^4.0" + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -2048,38 +2379,43 @@ ], "description": "Traverses array structures and object graphs to enumerate all referenced objects", "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2020-10-26T13:12:34+00:00" + "time": "2025-02-07T04:57:48+00:00" }, { "name": "sebastian/object-reflector", - "version": "2.0.4", + "version": "5.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7" + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", - "reference": "b4f479ebdbf63ac605d183ece17d8d7fe49c15c7", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -2099,38 +2435,43 @@ ], "description": "Allows reflection of object attributes, including inherited and non-public ones", "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" } ], - "time": "2020-10-26T13:14:26+00:00" + "time": "2025-02-07T04:58:17+00:00" }, { "name": "sebastian/recursion-context", - "version": "4.0.5", + "version": "7.0.1", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1" + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", - "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.3" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "4.0-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -2158,89 +2499,55 @@ ], "description": "Provides functionality to recursively process PHP variables", "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" - } - ], - "time": "2023-02-03T06:07:39+00:00" - }, - { - "name": "sebastian/resource-operations", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/sebastianbergmann/resource-operations.git", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/resource-operations/zipball/0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", - "reference": "0f4443cb3a1d92ce809899753bc0d5d5a8dd19a8", - "shasum": "" - }, - "require": { - "php": ">=7.3" - }, - "require-dev": { - "phpunit/phpunit": "^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "3.0-dev" - } - }, - "autoload": { - "classmap": [ - "src/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ + }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } - ], - "description": "Provides a list of PHP built-in functions that operate on resources", - "homepage": "https://www.github.com/sebastianbergmann/resource-operations", - "funding": [ + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, { - "url": "https://github.com/sebastianbergmann", - "type": "github" + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" } ], - "time": "2020-09-28T06:45:17+00:00" + "time": "2025-08-13T04:44:59+00:00" }, { "name": "sebastian/type", - "version": "3.2.1", + "version": "6.0.3", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/type.git", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7" + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", - "reference": "75e2c2a32f5e0b3aef905b9ed0b179b953b3d7c7", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/e549163b9760b8f71f191651d22acf32d56d6d4d", + "reference": "e549163b9760b8f71f191651d22acf32d56d6d4d", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "require-dev": { - "phpunit/phpunit": "^9.5" + "phpunit/phpunit": "^12.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.2-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2261,35 +2568,52 @@ ], "description": "Collection of value objects that represent the types of the PHP type system", "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.3" + }, "funding": [ { "url": "https://github.com/sebastianbergmann", "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" } ], - "time": "2023-02-03T06:13:03+00:00" + "time": "2025-08-09T06:57:12+00:00" }, { "name": "sebastian/version", - "version": "3.0.2", + "version": "6.0.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c6c1022351a901512170118436c764e473f6de8c" + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c", - "reference": "c6c1022351a901512170118436c764e473f6de8c", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", "shasum": "" }, "require": { - "php": ">=7.3" + "php": ">=8.3" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2310,210 +2634,106 @@ ], "description": "Library that helps with managing the version number of Git-hosted PHP projects", "homepage": "https://github.com/sebastianbergmann/version", - "funding": [ - { - "url": "https://github.com/sebastianbergmann", - "type": "github" - } - ], - "time": "2020-09-28T06:39:44+00:00" - }, - { - "name": "symfony/config", - "version": "v5.4.31", - "source": { - "type": "git", - "url": "https://github.com/symfony/config.git", - "reference": "dd5ea39de228813aba0c23c3a4153da2a4cf3cd9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/dd5ea39de228813aba0c23c3a4153da2a4cf3cd9", - "reference": "dd5ea39de228813aba0c23c3a4153da2a4cf3cd9", - "shasum": "" - }, - "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/filesystem": "^4.4|^5.0|^6.0", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-php80": "^1.16", - "symfony/polyfill-php81": "^1.22" - }, - "conflict": { - "symfony/finder": "<4.4" - }, - "require-dev": { - "symfony/event-dispatcher": "^4.4|^5.0|^6.0", - "symfony/finder": "^4.4|^5.0|^6.0", - "symfony/messenger": "^4.4|^5.0|^6.0", - "symfony/service-contracts": "^1.1|^2|^3", - "symfony/yaml": "^4.4|^5.0|^6.0" - }, - "suggest": { - "symfony/yaml": "To use the yaml reference dumper" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\Config\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", - "homepage": "https://symfony.com", "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2023-11-09T08:22:43+00:00" + "time": "2025-02-07T05:00:38+00:00" }, { - "name": "symfony/console", - "version": "v5.4.31", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/symfony/console.git", - "reference": "11ac5f154e0e5c4c77af83ad11ead9165280b92a" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/11ac5f154e0e5c4c77af83ad11ead9165280b92a", - "reference": "11ac5f154e0e5c4c77af83ad11ead9165280b92a", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php73": "^1.9", - "symfony/polyfill-php80": "^1.16", - "symfony/service-contracts": "^1.1|^2|^3", - "symfony/string": "^5.1|^6.0" - }, - "conflict": { - "psr/log": ">=3", - "symfony/dependency-injection": "<4.4", - "symfony/dotenv": "<5.1", - "symfony/event-dispatcher": "<4.4", - "symfony/lock": "<4.4", - "symfony/process": "<4.4" - }, - "provide": { - "psr/log-implementation": "1.0|2.0" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, "require-dev": { - "psr/log": "^1|^2", - "symfony/config": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/event-dispatcher": "^4.4|^5.0|^6.0", - "symfony/lock": "^4.4|^5.0|^6.0", - "symfony/process": "^4.4|^5.0|^6.0", - "symfony/var-dumper": "^4.4|^5.0|^6.0" - }, - "suggest": { - "psr/log": "For using the console logger", - "symfony/event-dispatcher": "", - "symfony/lock": "", - "symfony/process": "" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, "type": "library", "autoload": { - "psr-4": { - "Symfony\\Component\\Console\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases the creation of beautiful and testable command line interfaces", - "homepage": "https://symfony.com", + "description": "A static analysis tool to detect side effects in PHP code", "keywords": [ - "cli", - "command-line", - "console", - "terminal" + "static analysis" ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, "funding": [ { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", + "url": "https://github.com/staabm", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" } ], - "time": "2023-10-31T07:58:33+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { - "name": "symfony/filesystem", - "version": "v5.4.25", + "name": "symfony/config", + "version": "v7.4.8", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "0ce3a62c9579a53358d3a7eb6b3dfb79789a6364" + "url": "https://github.com/symfony/config.git", + "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/0ce3a62c9579a53358d3a7eb6b3dfb79789a6364", - "reference": "0ce3a62c9579a53358d3a7eb6b3dfb79789a6364", + "url": "https://api.github.com/repos/symfony/config/zipball/2d19dde43fa2ff720b9a40763ace7226594f503b", + "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Filesystem\\": "" + "Symfony\\Component\\Config\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -2533,8 +2753,11 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides basic utilities for the filesystem", + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.8" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -2544,53 +2767,69 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-05-31T13:04:02+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.28.0", + "name": "symfony/console", + "version": "v7.4.8", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb" + "url": "https://github.com/symfony/console.git", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", - "reference": "ea208ce43cbb04af6867b4fdddb1bdbf84cc28cb", + "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { - "ext-ctype": "*" + "psr/log-implementation": "1.0|2.0|3.0" }, - "suggest": { - "ext-ctype": "For best performance" + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2598,22 +2837,25 @@ ], "authors": [ { - "name": "Gert de Pagter", - "email": "BackEndTea@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for ctype functions", + "description": "Eases the creation of beautiful and testable command line interfaces", "homepage": "https://symfony.com", "keywords": [ - "compatibility", - "ctype", - "polyfill", - "portable" + "cli", + "command-line", + "console", + "terminal" ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.8" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -2623,50 +2865,48 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", - "version": "v1.28.0", + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "875e90aeea2777b6f135677f618529449334a612" + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/875e90aeea2777b6f135677f618529449334a612", - "reference": "875e90aeea2777b6f135677f618529449334a612", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", "shasum": "" }, "require": { - "php": ">=7.1" - }, - "suggest": { - "ext-intl": "For best performance" + "php": ">=8.1" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" - } + "function.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2682,16 +2922,11 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "grapheme", - "intl", - "polyfill", - "portable", - "shim" - ], + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -2706,47 +2941,37 @@ "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", - "version": "v1.28.0", + "name": "symfony/filesystem", + "version": "v7.4.8", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92" + "url": "https://github.com/symfony/filesystem.git", + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", - "reference": "8c4ad05dd0120b6a53c1ca374dca2ad0a1c4ed92", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5", + "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" }, - "suggest": { - "ext-intl": "For best performance" + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + "Symfony\\Component\\Filesystem\\": "" }, - "classmap": [ - "Resources/stubs" + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2755,24 +2980,19 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "intl", - "normalizer", - "polyfill", - "portable", - "shim" - ], + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.8" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -2782,44 +3002,45 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { - "name": "symfony/polyfill-mbstring", - "version": "v1.28.0", + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "42292d99c55abe617799667f454222c54c60e229" + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/42292d99c55abe617799667f454222c54c60e229", - "reference": "42292d99c55abe617799667f454222c54c60e229", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { - "ext-mbstring": "*" + "ext-ctype": "*" }, "suggest": { - "ext-mbstring": "For best performance" + "ext-ctype": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -2827,7 +3048,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" + "Symfony\\Polyfill\\Ctype\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -2836,23 +3057,25 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Symfony polyfill for ctype functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "mbstring", + "ctype", "polyfill", - "portable", - "shim" + "portable" ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -2862,38 +3085,42 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-07-28T09:04:16+00:00" + "time": "2026-04-10T16:19:22+00:00" }, { - "name": "symfony/polyfill-php73", - "version": "v1.28.0", + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php73.git", - "reference": "fe2f306d1d9d346a7fee353d0d5012e401e984b5" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php73/zipball/fe2f306d1d9d346a7fee353d0d5012e401e984b5", - "reference": "fe2f306d1d9d346a7fee353d0d5012e401e984b5", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", + "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -2901,11 +3128,8 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php73\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2921,14 +3145,19 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 7.3+ features to lower PHP versions", + "description": "Symfony polyfill for intl's grapheme_* functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "grapheme", + "intl", "polyfill", "portable", "shim" ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -2938,38 +3167,42 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2026-04-26T13:13:48+00:00" }, { - "name": "symfony/polyfill-php80", - "version": "v1.28.0", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/6caa57379c4aec19c0a12a38b59b26487dcfe4b5", - "reference": "6caa57379c4aec19c0a12a38b59b26487dcfe4b5", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -2977,7 +3210,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" }, "classmap": [ "Resources/stubs" @@ -2988,10 +3221,6 @@ "MIT" ], "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -3001,14 +3230,19 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Symfony polyfill for intl's Normalizer class and related functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "intl", + "normalizer", "polyfill", "portable", "shim" ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -3018,38 +3252,46 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/polyfill-php81", - "version": "v1.28.0", + "name": "symfony/polyfill-mbstring", + "version": "v1.37.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/7581cd600fa9fd681b797d00b02f068e2f13263b", - "reference": "7581cd600fa9fd681b797d00b02f068e2f13263b", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", "shasum": "" }, "require": { - "php": ">=7.1" + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "1.28-dev" - }, "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -3057,11 +3299,8 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3077,14 +3316,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "mbstring", "polyfill", "portable", "shim" ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -3094,52 +3337,56 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-01-26T09:26:14+00:00" + "time": "2026-04-10T17:25:58+00:00" }, { "name": "symfony/service-contracts", - "version": "v2.5.2", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c" + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/4b426aac47d6427cc1a1d0f7e2ac724627f5966c", - "reference": "4b426aac47d6427cc1a1d0f7e2ac724627f5966c", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { - "php": ">=7.2.5", - "psr/container": "^1.1", - "symfony/deprecation-contracts": "^2.1|^3" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { "ext-psr": "<1.1|>=2" }, - "suggest": { - "symfony/service-implementation": "" - }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "2.5-dev" - }, "thanks": { - "name": "symfony/contracts", - "url": "https://github.com/symfony/contracts" + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" } }, "autoload": { "psr-4": { "Symfony\\Contracts\\Service\\": "" - } + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3165,6 +3412,9 @@ "interoperability", "standards" ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -3174,30 +3424,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2022-05-30T19:17:29+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/stopwatch", - "version": "v5.4.21", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "f83692cd869a6f2391691d40a01e8acb89e76fee" + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/f83692cd869a6f2391691d40a01e8acb89e76fee", - "reference": "f83692cd869a6f2391691d40a01e8acb89e76fee", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/service-contracts": "^1|^2|^3" + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" }, "type": "library", "autoload": { @@ -3224,6 +3478,9 @@ ], "description": "Provides a way to profile code", "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -3233,43 +3490,48 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-02-14T08:03:56+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/string", - "version": "v5.4.31", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "2765096c03f39ddf54f6af532166e42aaa05b24b" + "reference": "114ac57257d75df748eda23dd003878080b8e688" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/2765096c03f39ddf54f6af532166e42aaa05b24b", - "reference": "2765096c03f39ddf54f6af532166e42aaa05b24b", + "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688", + "reference": "114ac57257d75df748eda23dd003878080b8e688", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "~1.15" + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/translation-contracts": ">=3.0" + "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^4.4|^5.0|^6.0", - "symfony/http-client": "^4.4|^5.0|^6.0", - "symfony/translation-contracts": "^1.1|^2", - "symfony/var-exporter": "^4.4|^5.0|^6.0" + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3307,6 +3569,9 @@ "utf-8", "utf8" ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.8" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -3316,40 +3581,41 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-11-09T08:19:44+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/yaml", - "version": "v5.4.31", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "f387675d7f5fc4231f7554baa70681f222f73563" + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/f387675d7f5fc4231f7554baa70681f222f73563", - "reference": "f387675d7f5fc4231f7554baa70681f222f73563", + "url": "https://api.github.com/repos/symfony/yaml/zipball/c58fdf7b3d6c2995368264c49e4e8b05bcff2883", + "reference": "c58fdf7b3d6c2995368264c49e4e8b05bcff2883", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-ctype": "^1.8" }, "conflict": { - "symfony/console": "<5.3" + "symfony/console": "<6.4" }, "require-dev": { - "symfony/console": "^5.3|^6.0" - }, - "suggest": { - "symfony/console": "For validating YAML files using the lint command" + "symfony/console": "^6.4|^7.0|^8.0" }, "bin": [ "Resources/bin/yaml-lint" @@ -3379,6 +3645,9 @@ ], "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.8" + }, "funding": [ { "url": "https://symfony.com/sponsor", @@ -3388,32 +3657,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-11-03T14:41:28+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "theseer/tokenizer", - "version": "1.2.1", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/theseer/tokenizer.git", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", - "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", "shasum": "" }, "require": { "ext-dom": "*", "ext-tokenizer": "*", "ext-xmlwriter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.1" }, "type": "library", "autoload": { @@ -3433,24 +3706,31 @@ } ], "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, "funding": [ { "url": "https://github.com/theseer", "type": "github" } ], - "time": "2021-07-28T10:34:58+00:00" + "time": "2025-12-08T11:19:18+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=7.3", + "php": "^8.3", "ext-json": "*" }, - "platform-dev": [], - "plugin-api-version": "1.1.0" + "platform-dev": {}, + "platform-overrides": { + "php": "8.3.30" + }, + "plugin-api-version": "2.9.0" } diff --git a/examples/example.php b/examples/example.php index 623d900..dba5cee 100644 --- a/examples/example.php +++ b/examples/example.php @@ -1,10 +1,37 @@ accessKey('your-access-key-goes-here'); -$result = $fixer->from(Symbol::EUR)->get(Symbol::USD); +// Frankfurter is free and key-less, so this example runs end-to-end with no setup. +$frankfurter = DriverFactory::make('frankfurter'); + +$result = $frankfurter + ->from(Currency::EUR) + ->to([Currency::USD, Currency::GBP, Currency::JPY]) + ->get(); + +echo 'Base: ', $result->getBaseCurrency(), PHP_EOL; +echo 'Date: ', $result->getDate() ?? '(unknown)', PHP_EOL; +echo 'EUR -> USD: ', $result->rate(Currency::USD), PHP_EOL; +echo 'EUR -> GBP: ', $result->rate(Currency::GBP), PHP_EOL; +echo '100 EUR -> JPY: ', $result->convert(100, Currency::EUR, Currency::JPY), PHP_EOL; + +// Rebase the same dataset to USD without re-fetching. +$rebased = $result->setBaseCurrency(Currency::USD); +echo 'USD -> EUR: ', $rebased->rate(Currency::EUR), PHP_EOL; -print_r($result->all()); +// For paid providers, swap driver name and pass an access key: +// +// $fixer = DriverFactory::make('fixerio'); +// $fixer->accessKey('your-fixer-io-key')->from(Currency::EUR)->to(Currency::USD)->get(); +// +// $currencyApi = DriverFactory::make('currencyapi'); +// $currencyApi->accessKey('your-currencyapi-key')->from(Currency::USD)->to(Currency::EUR)->get(); +// +// $fastForex = DriverFactory::make('fastforex'); +// $fastForex->accessKey('your-fastforex-key')->from(Currency::USD)->to(Currency::EUR)->get(); diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..c1016f9 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,24 @@ +--- +# Lefthook configuration +# For more information: https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md + +pre-commit: + parallel: true + commands: + pint: + glob: "*.php" + run: vendor/bin/pint --test {staged_files} + phpstan: + run: vendor/bin/phpstan analyse --no-progress + rector: + run: vendor/bin/rector --dry-run + phpunit: + run: vendor/bin/phpunit --no-coverage + markdownlint: + glob: "*.md" + run: markdownlint-cli2 {staged_files} + yamllint: + glob: "*.{yml,yaml}" + run: yamllint {staged_files} + qlty: + run: ~/.qlty/bin/qlty check diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..589ce73 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,16 @@ + + + PSR-12 checks for qlty/php-codesniffer. + + src + examples + rector.php + + build/* + tests/* + vendor/* + + + + + diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..18eb39a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,23 @@ +parameters: + level: max + paths: + - src + - tests + treatPhpDocTypesAsCertain: false + tmpDir: build/phpstan + ignoreErrors: + - + identifier: cast.string + path: src/Drivers/*.php + - + identifier: cast.int + path: src/Drivers/*.php + - + identifier: encapsedStringPart.nonString + path: src/Drivers/*.php + - + identifier: binaryOp.invalid + path: src/Drivers/*.php + - + identifier: offsetAccess.nonOffsetAccessible + path: src/Drivers/*.php diff --git a/phpunit.xml b/phpunit.xml index 59f01b0..c0f7e1a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,25 +1,31 @@ - - - - ./src - - - ./vendor - ./tests - ./examples - - - - - - - - - ./tests/ - - - - - + + + + tests + + + + + src + + + + + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..6788de6 --- /dev/null +++ b/pint.json @@ -0,0 +1,12 @@ +{ + "preset": "psr12", + "rules": { + "declare_strict_types": true, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "no_unused_imports": true, + "single_quote": true, + "trailing_comma_in_multiline": true + } +} diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..1d4d4ae --- /dev/null +++ b/rector.php @@ -0,0 +1,21 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withSets([ + LevelSetList::UP_TO_PHP_83, + SetList::CODE_QUALITY, + SetList::TYPE_DECLARATION, + PHPUnitSetList::PHPUNIT_100, + ]) + ->withImportNames(removeUnusedImports: true); diff --git a/renovate.json b/renovate.json deleted file mode 100644 index f45d8f1..0000000 --- a/renovate.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "extends": [ - "config:base" - ] -} diff --git a/resources/README.md b/resources/README.md new file mode 100644 index 0000000..a1c479e --- /dev/null +++ b/resources/README.md @@ -0,0 +1,17 @@ +# Resources + +This folder contains assets used for the project, such as social preview images. + +## Open Graph Preview Image + +The open graph preview image is provided as an SVG vector file (`open-graph-preview.svg`) to ensure it's easily editable and scalable. + +If you need to generate a PNG version of the open graph image (e.g., for platforms that don't support SVG), you can use `npx` (requires Node.js) with the `sharp-cli` tool. + +Run the following command in your terminal from the root directory of the project: + +```bash +npx -y sharp-cli@latest -i resources/open-graph-preview.svg -o resources/open-graph-preview.png +``` + +This will correctly read the SVG's 1280x640 dimensions and render a high-quality `open-graph-preview.png` without any cropping or weird padding issues that can happen with built-in OS tools. diff --git a/resources/open-graph-preview.png b/resources/open-graph-preview.png new file mode 100644 index 0000000..1392f2b Binary files /dev/null and b/resources/open-graph-preview.png differ diff --git a/resources/open-graph-preview.psd b/resources/open-graph-preview.psd deleted file mode 100644 index 105af84..0000000 Binary files a/resources/open-graph-preview.psd and /dev/null differ diff --git a/resources/open-graph-preview.svg b/resources/open-graph-preview.svg new file mode 100644 index 0000000..a29f4e5 --- /dev/null +++ b/resources/open-graph-preview.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + Currency::from('USD') + ->to(['EUR', 'GBP', 'JPY']) + ->amount(150.00) + ->get(); + + + + + + + + PHP + + + Currency API + + + A unified, fluent abstraction for + third-party currency-rate REST APIs. + + + + + + Fixer.io + + + CurrencyLayer + + + OpenExchangeRates + + + FastForex + + + + ExchangeRatesApi + + + Frankfurter + + + CurrencyApi + + + + + + + + + + + + + $ + + + + + + + + + + £ + + + + + ¥ + + + + + Fr + + + diff --git a/src/Currency.php b/src/Currency.php new file mode 100644 index 0000000..0a3ccc3 --- /dev/null +++ b/src/Currency.php @@ -0,0 +1,364 @@ + 'United Arab Emirates Dirham', + 'AFN' => 'Afghan Afghani', + 'ALL' => 'Albanian Lek', + 'AMD' => 'Armenian Dram', + 'ANG' => 'Netherlands Antillean Guilder', + 'AOA' => 'Angolan Kwanza', + 'ARS' => 'Argentine Peso', + 'AUD' => 'Australian Dollar', + 'AWG' => 'Aruban Florin', + 'AZN' => 'Azerbaijani Manat', + 'BAM' => 'Bosnia Herzegovina Convertible Mark', + 'BBD' => 'Barbadian Dollar', + 'BDT' => 'Bangladeshi Taka', + 'BGN' => 'Bulgarian Lev', + 'BHD' => 'Bahraini Dinar', + 'BIF' => 'Burundian Franc', + 'BMD' => 'Bermudan Dollar', + 'BND' => 'Brunei Dollar', + 'BOB' => 'Bolivian Boliviano', + 'BRL' => 'Brazilian Real', + 'BSD' => 'Bahamian Dollar', + 'BTC' => 'Bitcoin', + 'BTN' => 'Bhutanese Ngultrum', + 'BWP' => 'Botswanan Pula', + 'BYR' => 'Belarusian Ruble', + 'BYN' => 'New Belarusian Ruble', + 'BZD' => 'Belize Dollar', + 'CAD' => 'Canadian Dollar', + 'CDF' => 'Congolese Franc', + 'CHF' => 'Swiss Franc', + 'CLF' => 'Chilean Unit of Account', + 'CLP' => 'Chilean Peso', + 'CNY' => 'Chinese Yuan', + 'COP' => 'Colombian Peso', + 'CRC' => 'Costa Rican Colón', + 'CUC' => 'Cuban Convertible Peso', + 'CUP' => 'Cuban Peso', + 'CVE' => 'Cape Verdean Escudo', + 'CZK' => 'Czech Republic Koruna', + 'DJF' => 'Djiboutian Franc', + 'DKK' => 'Danish Krone', + 'DOP' => 'Dominican Peso', + 'DZD' => 'Algerian Dinar', + 'EGP' => 'Egyptian Pound', + 'ERN' => 'Eritrean Nakfa', + 'ETB' => 'Ethiopian Birr', + 'EUR' => 'Euro', + 'FJD' => 'Fijian Dollar', + 'FKP' => 'Falkland Islands Pound', + 'GBP' => 'British Pound Sterling', + 'GEL' => 'Georgian Lari', + 'GGP' => 'Guernsey Pound', + 'GHS' => 'Ghanaian Cedi', + 'GIP' => 'Gibraltar Pound', + 'GMD' => 'Gambian Dalasi', + 'GNF' => 'Guinean Franc', + 'GTQ' => 'Guatemalan Quetzal', + 'GYD' => 'Guyanaese Dollar', + 'HKD' => 'Hong Kong Dollar', + 'HNL' => 'Honduran Lempira', + 'HRK' => 'Croatian Kuna', + 'HTG' => 'Haitian Gourde', + 'HUF' => 'Hungarian Forint', + 'IDR' => 'Indonesian Rupiah', + 'ILS' => 'Israeli New Sheqel', + 'IMP' => 'Manx pound', + 'INR' => 'Indian Rupee', + 'IQD' => 'Iraqi Dinar', + 'IRR' => 'Iranian Rial', + 'ISK' => 'Icelandic Króna', + 'JEP' => 'Jersey Pound', + 'JMD' => 'Jamaican Dollar', + 'JOD' => 'Jordanian Dinar', + 'JPY' => 'Japanese Yen', + 'KES' => 'Kenyan Shilling', + 'KGS' => 'Kyrgystani Som', + 'KHR' => 'Cambodian Riel', + 'KMF' => 'Comorian Franc', + 'KPW' => 'North Korean Won', + 'KRW' => 'South Korean Won', + 'KWD' => 'Kuwaiti Dinar', + 'KYD' => 'Cayman Islands Dollar', + 'KZT' => 'Kazakhstani Tenge', + 'LAK' => 'Laotian Kip', + 'LBP' => 'Lebanese Pound', + 'LKR' => 'Sri Lankan Rupee', + 'LRD' => 'Liberian Dollar', + 'LSL' => 'Lesotho Loti', + 'LTL' => 'Lithuanian Litas', + 'LVL' => 'Latvian Lats', + 'LYD' => 'Libyan Dinar', + 'MAD' => 'Moroccan Dirham', + 'MDL' => 'Moldovan Leu', + 'MGA' => 'Malagasy Ariary', + 'MKD' => 'Macedonian Denar', + 'MMK' => 'Myanma Kyat', + 'MNT' => 'Mongolian Tugrik', + 'MOP' => 'Macanese Pataca', + 'MRO' => 'Mauritanian Ouguiya', + 'MUR' => 'Mauritian Rupee', + 'MVR' => 'Maldivian Rufiyaa', + 'MWK' => 'Malawian Kwacha', + 'MXN' => 'Mexican Peso', + 'MYR' => 'Malaysian Ringgit', + 'MZN' => 'Mozambican Metical', + 'NAD' => 'Namibian Dollar', + 'NGN' => 'Nigerian Naira', + 'NIO' => 'Nicaraguan Córdoba', + 'NOK' => 'Norwegian Krone', + 'NPR' => 'Nepalese Rupee', + 'NZD' => 'New Zealand Dollar', + 'OMR' => 'Omani Rial', + 'PAB' => 'Panamanian Balboa', + 'PEN' => 'Peruvian Nuevo Sol', + 'PGK' => 'Papua New Guinean Kina', + 'PHP' => 'Philippine Peso', + 'PKR' => 'Pakistani Rupee', + 'PLN' => 'Polish Zloty', + 'PYG' => 'Paraguayan Guarani', + 'QAR' => 'Qatari Rial', + 'RON' => 'Romanian Leu', + 'RSD' => 'Serbian Dinar', + 'RUB' => 'Russian Ruble', + 'RWF' => 'Rwandan Franc', + 'SAR' => 'Saudi Riyal', + 'SBD' => 'Solomon Islands Dollar', + 'SCR' => 'Seychellois Rupee', + 'SDG' => 'Sudanese Pound', + 'SEK' => 'Swedish Krona', + 'SGD' => 'Singapore Dollar', + 'SHP' => 'Saint Helena Pound', + 'SLL' => 'Sierra Leonean Leone', + 'SOS' => 'Somali Shilling', + 'SRD' => 'Surinamese Dollar', + 'STD' => 'São Tomé and Príncipe Dobra', + 'SVC' => 'Salvadoran Colón', + 'SYP' => 'Syrian Pound', + 'SZL' => 'Swazi Lilangeni', + 'THB' => 'Thai Baht', + 'TJS' => 'Tajikistani Somoni', + 'TMT' => 'Turkmenistani Manat', + 'TND' => 'Tunisian Dinar', + 'TOP' => 'Tongan Paʻanga', + 'TRY' => 'Turkish Lira', + 'TTD' => 'Trinidad and Tobago Dollar', + 'TWD' => 'New Taiwan Dollar', + 'TZS' => 'Tanzanian Shilling', + 'UAH' => 'Ukrainian Hryvnia', + 'UGX' => 'Ugandan Shilling', + 'USD' => 'United States Dollar', + 'UYU' => 'Uruguayan Peso', + 'UZS' => 'Uzbekistan Som', + 'VEF' => 'Venezuelan Bolívar Fuerte', + 'VND' => 'Vietnamese Dong', + 'VUV' => 'Vanuatu Vatu', + 'WST' => 'Samoan Tala', + 'XAF' => 'CFA Franc BEAC', + 'XAG' => 'Silver', + 'XAU' => 'Gold', + 'XCD' => 'East Caribbean Dollar', + 'XDR' => 'Special Drawing Rights', + 'XOF' => 'CFA Franc BCEAO', + 'XPF' => 'CFP Franc', + 'YER' => 'Yemeni Rial', + 'ZAR' => 'South African Rand', + 'ZMW' => 'Zambian Kwacha', + 'ZWL' => 'Zimbabwean Dollar', + ]; + + public static function tryFromCode(string $code): ?self + { + return self::tryFrom($code); + } + + public function displayName(): string + { + return self::NAMES[$this->value] ?? $this->value; + } + + /** + * Coerce a string code or Currency enum to its uppercase string code. + */ + public static function code(string|self $code): string + { + return $code instanceof self ? $code->value : $code; + } +} diff --git a/src/DriverFactory.php b/src/DriverFactory.php index 3356698..d0d02a8 100644 --- a/src/DriverFactory.php +++ b/src/DriverFactory.php @@ -1,58 +1,180 @@ - MockCurrencyDriver::class, - 'fixerio' => FixerIo::class, - 'currencylayer' => CurrencyLayer::class, - 'openexchangerates' => OpenExchangeRates::class, - 'exchangeratesapi' => ExchangeRatesApi::class, - ]; - - /** - * @param string $name - * @param ClientInterface|null $client - * - * @return CurrencyDriverContract - * - * @throws DriverNotFoundException - */ - public static function make(string $name, ClientInterface $client = null): CurrencyDriverContract - { - if (!isset(static::DRIVERS[$name])) { - throw new DriverNotFoundException("{$name} is not a valid driver."); - } - - $class = static::DRIVERS[$name]; - - // If no client is specified, create a HTTPClient instance. - $client = $client == null ? new HTTPClient() : $client; - return new $class($client); - } - - /** - * Get all of the available drivers. - * - * @return array - */ - public static function getDrivers(): array - { - return self::DRIVERS; - } + /** + * @var array> + */ + private array $drivers; + + private static ?self $defaultInstance = null; + + /** + * @param array>|null $drivers + */ + public function __construct(?array $drivers = null) + { + $this->drivers = $drivers ?? [ + 'mock' => MockCurrencyDriver::class, + 'fixerio' => FixerIo::class, + 'currencylayer' => CurrencyLayer::class, + 'openexchangerates' => OpenExchangeRates::class, + 'exchangeratesapi' => ExchangeRatesApi::class, + 'frankfurter' => Frankfurter::class, + 'currencyapi' => CurrencyApi::class, + 'fastforex' => FastForex::class, + ]; + } + + /** + * @param class-string $driverClass + */ + public function register(string $name, string $driverClass): self + { + $this->drivers[$name] = $driverClass; + + return $this; + } + + public function unregister(string $name): self + { + unset($this->drivers[$name]); + + return $this; + } + + /** + * @return array> + */ + public function drivers(): array + { + return $this->drivers; + } + + /** + * @throws DriverNotFoundException + */ + public function build( + string $name, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ): CurrencyDriverContract { + if (!isset($this->drivers[$name])) { + throw new DriverNotFoundException("{$name} is not a valid driver."); + } + + $class = $this->drivers[$name]; + $client = $httpClient ?? $this->defaultClient(); + $factory = $requestFactory ?? $this->defaultRequestFactory(); + + return new $class($client, $factory); + } + + /** + * Static facade preserved for backwards compatibility. + * + * @throws DriverNotFoundException + */ + public static function make( + string $name, + ?ClientInterface $httpClient = null, + ?RequestFactoryInterface $requestFactory = null, + ): CurrencyDriverContract { + return self::default()->build($name, $httpClient, $requestFactory); + } + + /** + * @return array> + */ + public static function getDrivers(): array + { + return self::default()->drivers(); + } + + public static function default(): self + { + return self::$defaultInstance ??= new self(); + } + + public static function setDefault(?self $instance): void + { + self::$defaultInstance = $instance; + } + + private function defaultClient(): ClientInterface + { + if (!class_exists(GuzzleClient::class)) { + throw new MissingDependencyException( + 'No PSR-18 HTTP client supplied and guzzlehttp/guzzle is ' + . 'not installed. Either install guzzlehttp/guzzle, or pass ' + . 'a ClientInterface to DriverFactory::make().', + ); + } + + $client = $this->buildDefaultClient(); + if (!$client instanceof ClientInterface) { + throw new MissingDependencyException( + 'The installed guzzlehttp/guzzle package does not provide a PSR-18 ' + . 'ClientInterface implementation.', + ); + } + + return $client; + } + + private function defaultRequestFactory(): RequestFactoryInterface + { + if (!class_exists(GuzzleRequestFactory::class)) { + throw new MissingDependencyException( + 'No PSR-17 RequestFactory supplied and ' + . 'http-interop/http-factory-guzzle is not installed. ' + . 'Either install http-interop/http-factory-guzzle, or pass ' + . 'a RequestFactoryInterface to DriverFactory::make().', + ); + } + + $requestFactory = $this->buildDefaultRequestFactory(); + if (!$requestFactory instanceof RequestFactoryInterface) { + throw new MissingDependencyException( + 'The installed http-interop/http-factory-guzzle package does not ' + . 'provide a PSR-17 RequestFactoryInterface implementation.', + ); + } + + return $requestFactory; + } + + private function buildDefaultClient(): object + { + $class = GuzzleClient::class; + + return new $class(); + } + + private function buildDefaultRequestFactory(): object + { + $class = GuzzleRequestFactory::class; + + return new $class(); + } } diff --git a/src/Drivers/BaseCurrencyDriver.php b/src/Drivers/BaseCurrencyDriver.php index a8f8fc1..1a8c32b 100644 --- a/src/Drivers/BaseCurrencyDriver.php +++ b/src/Drivers/BaseCurrencyDriver.php @@ -1,227 +1,342 @@ - 'application/json', - 'Content-Type' => 'application/json', - ]; - - protected $currencies = []; - protected $baseCurrency = 'USD'; - protected $amount = 0.00; - protected $date = null; - - protected $httpClient = null; - protected $httpParams = []; - - /** - * BaseDriver constructor. - * - * @param ClientInterface $client - */ - public function __construct(ClientInterface $client) - { - $this->httpClient = $client; - } - - /** - * @param string $baseCurrency - * - * @return self - */ - public function source(string $baseCurrency): CurrencyDriverContract - { - $this->baseCurrency = $baseCurrency; - return $this; - } - - /** - * Alias for 'source'. - * - * @param string $baseCurrency - * - * @return CurrencyDriverContract - * @see CurrencyDriverContract::source() - * - */ - public function from(string $baseCurrency): CurrencyDriverContract - { - return $this->source($baseCurrency); - } - - /** - * @param string|array $symbols - * - * @return self - */ - public function currencies($symbols = []): CurrencyDriverContract - { - $this->currencies = (array)$symbols; - return $this; - } - - /** - * Alias for 'currencies'. - * - * @param array $symbols - * - * @return CurrencyDriverContract - * @see CurrencyDriverContract::currencies() - * - */ - public function to($symbols = []): CurrencyDriverContract - { - return $this->currencies($symbols); - } - - /** - * @param double|integer|float $amount - * - * @return self - */ - public function amount($amount): CurrencyDriverContract - { - $this->amount = $amount; - return $this; - } - - /** - * @param int|string|DateTime|DateInterval|DateTimeInterface $date - * - * @return self - * - * @throws Exception - */ - public function date($date): CurrencyDriverContract - { - if ($date === null) { - return $this; - } - - $this->date = DateHelper::format($date, 'Y-m-d'); - return $this; - } - - /** - * Returns the date in 'YYYY-mm-dd' format or null if not set. - * - * @return string|null - */ - public function getDate(): ?string - { - return $this->date; - } - - /** - * @return array - */ - public function getSymbols(): array - { - return $this->currencies; - } - - /** - * @return string - */ - public function getBaseCurrency(): string - { - return $this->baseCurrency; - } - - /** - * @return self - */ - public function secure(): CurrencyDriverContract - { - $this->protocol = 'https'; - return $this; - } - - /** - * @return string - */ - public function getProtocol(): string - { - return $this->protocol; - } - - /** - * Set a config parameter. - * - * @param string $key - * @param string $value - * - * @return self - */ - public function config(string $key, string $value): CurrencyDriverContract - { - $this->httpParams[$key] = $value; - return $this; - } - - /** - * Sets the API key to use. - * - * Shortcut for config('access_key', $accessKey) - * - * @param string $accessKey Your API key. - * - * @return self - * @see CurrencyDriverContract::config() - * - */ - public function accessKey(string $accessKey): CurrencyDriverContract - { - $this->config('access_key', $accessKey); - return $this; - } - - /** - * Performs an HTTP request. - * - * @param string $endpoint The API endpoint. - * @param array $params The query parameters for this request. - * @param string $method The HTTP method (defaults to 'GET'). - * - * @return array|bool The response as decoded JSON. - * - * @throws ApiException - */ - function apiRequest(string $endpoint, array $params = [], string $method = 'GET') - { - $url = sprintf('%s://%s/%s', $this->getProtocol(), $this->apiURL, $endpoint); - - try { - $response = $this->httpClient->request($method, $url, ['query' => array_merge($this->httpParams, $params)])->getBody(); - } catch (GuzzleException $e) { - throw new ApiException($e->getMessage(), $e->getCode(), $e); - } - - $data = json_decode($response->getContents(), true); - - // Check for JSON errors - if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { - throw new ApiException(json_last_error_msg(), json_last_error()); - } - - // Otherwise return data. - return $data; - } + protected string $apiURL = 'localhost'; + protected string $protocol = 'https'; + protected string $baseCurrency; + + /** @var list */ + protected array $currencies = []; + + protected ?float $amount = null; + protected ?string $date = null; + + /** @var array */ + protected array $httpParams = []; + + /** @var array */ + protected array $httpHeaders = []; + + public function __construct( + private readonly ClientInterface $httpClient, + private readonly RequestFactoryInterface $requestFactory, + ) { + if (!isset($this->baseCurrency)) { + $this->baseCurrency = 'USD'; + } + } + + public function source(string|Currency $baseCurrency): static + { + $this->baseCurrency = Currency::code($baseCurrency); + + return $this; + } + + public function from(string|Currency $baseCurrency): static + { + return $this->source($baseCurrency); + } + + public function currencies(string|Currency|array $symbols = []): static + { + $list = is_array($symbols) ? $symbols : [$symbols]; + + $this->currencies = array_values(array_map( + Currency::code(...), + $list, + )); + + return $this; + } + + public function to(string|Currency|array $symbols = []): static + { + return $this->currencies($symbols); + } + + public function amount(?float $amount): static + { + $this->amount = $amount; + + return $this; + } + + public function date(?DateTimeInterface $date): static + { + $this->date = DateHelper::format($date); + + return $this; + } + + public function getDate(): ?string + { + return $this->date; + } + + /** + * @return list + */ + public function getSymbols(): array + { + return $this->currencies; + } + + public function getBaseCurrency(): string + { + return $this->baseCurrency; + } + + public function secure(): static + { + $this->protocol = 'https'; + + return $this; + } + + public function get(string|Currency|array $forCurrency = []): ConversionResult + { + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + $response = $this->apiRequest('latest', [ + 'base' => $this->getBaseCurrency(), + 'symbols' => implode(',', $this->getSymbols()), + ]); + + return new ConversionResult( + $this->responseString($response, 'base', static::class), + $this->responseString($response, 'date', static::class), + $this->responseRates($response, 'rates', static::class), + ); + } + + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + if ($this->getDate() === null) { + throw new ApiException('Date needs to be set!'); + } + + $response = $this->apiRequest($this->getDate(), [ + 'base' => $this->getBaseCurrency(), + 'symbols' => implode(',', $this->getSymbols()), + ]); + + return new ConversionResult( + $this->responseString($response, 'base', static::class), + $this->responseString($response, 'date', static::class), + $this->responseRates($response, 'rates', static::class), + ); + } + + public function convert( + ?float $amount = null, + string|Currency|null $fromCurrency = null, + string|Currency|null $toCurrency = null, + ?DateTimeInterface $date = null, + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($amount !== null) { + $this->amount = $amount; + } + + if ($fromCurrency !== null) { + $this->baseCurrency = Currency::code($fromCurrency); + } + + if ($toCurrency !== null) { + $this->currencies = [Currency::code($toCurrency)]; + } + + $target = $this->currencies[0] ?? null; + if ($target === null) { + throw new ApiException('A target currency is required for convert().'); + } + if ($this->amount === null) { + throw new ApiException('An amount is required for convert().'); + } + + $params = [ + 'from' => $this->getBaseCurrency(), + 'to' => $target, + 'amount' => $this->amount, + ]; + + if ($this->getDate() !== null) { + $params['date'] = $this->getDate(); + } + + $response = $this->apiRequest('convert', $params); + + $rate = BigDecimal::of($this->responseString($response, 'result', static::class)) + ->dividedBy(BigDecimal::of((string) $this->amount), ConversionResult::DEFAULT_SCALE, RoundingMode::HALF_UP); + + return new ConversionResult( + $this->getBaseCurrency(), + $this->optionalResponseString($response, 'date') ?? $this->getDate(), + [$target => $rate], + ); + } + + public function getProtocol(): string + { + return $this->protocol; + } + + public function config(string $key, string $value): static + { + $this->httpParams[$key] = $value; + + return $this; + } + + public function accessKey(string $accessKey): static + { + return $this->config('access_key', $accessKey); + } + + /** + * Performs an HTTP GET against the driver's API and decodes the JSON body. + * + * @param array $params + * + * @return array + * + * @throws ApiException + */ + protected function apiRequest(string $endpoint, array $params = []): array + { + $query = http_build_query([...$this->httpParams, ...$params]); + $uri = sprintf( + '%s://%s/%s%s', + $this->protocol, + $this->apiURL, + ltrim($endpoint, '/'), + $query === '' ? '' : '?' . $query, + ); + + $request = $this->requestFactory->createRequest('GET', $uri) + ->withHeader('Accept', 'application/json'); + + foreach ($this->httpHeaders as $name => $value) { + $request = $request->withHeader($name, $value); + } + + try { + $response = $this->httpClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + throw new ApiException($e->getMessage(), $e->getCode(), $e); + } + + $body = (string) $response->getBody(); + $statusCode = $response->getStatusCode(); + + if ($statusCode < 200 || $statusCode >= 300) { + throw new ApiException(trim($body) === '' ? "API request failed with HTTP {$statusCode}." : $body, $statusCode); + } + + try { + /** @var array $data */ + $data = json_decode($body, true, flags: JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new ApiException($e->getMessage(), $e->getCode(), $e); + } + + if (!is_array($data)) { + throw new ApiException('Expected JSON object from API, got ' . get_debug_type($data) . '.'); + } + + return $data; + } + + /** + * @param array $response + */ + protected function responseString(array $response, string $key, string $provider): string + { + $value = $response[$key] ?? null; + if (!is_scalar($value)) { + throw new ApiException("{$provider} response did not contain {$key}."); + } + + return (string) $value; + } + + /** + * @param array $response + */ + protected function optionalResponseString(array $response, string $key): ?string + { + $value = $response[$key] ?? null; + + return is_scalar($value) ? (string) $value : null; + } + + /** + * @param array $response + */ + protected function responseInt(array $response, string $key, string $provider): int + { + $value = $response[$key] ?? null; + if (!is_scalar($value)) { + throw new ApiException("{$provider} response did not contain {$key}."); + } + + return (int) $value; + } + + /** + * @param array $response + * + * @return array + */ + protected function responseRates(array $response, string $key, string $provider): array + { + $rates = $response[$key] ?? null; + if (!is_array($rates)) { + throw new ApiException("{$provider} response did not contain {$key}."); + } + + $normalised = []; + foreach ($rates as $currency => $rate) { + if (!$rate instanceof BigDecimal && !is_float($rate) && !is_int($rate) && !is_string($rate)) { + throw new ApiException("{$provider} response did not contain a numeric rate for {$currency}."); + } + + $normalised[(string) $currency] = $rate; + } + + return $normalised; + } } diff --git a/src/Drivers/CurrencyApi.php b/src/Drivers/CurrencyApi.php new file mode 100644 index 0000000..e72dd0e --- /dev/null +++ b/src/Drivers/CurrencyApi.php @@ -0,0 +1,185 @@ +httpHeaders['apikey'] = $accessKey; + + return $this; + } + + #[Override] + public function get(string|Currency|array $forCurrency = []): ConversionResult + { + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + $response = $this->apiRequest('v3/latest', $this->buildRateParams()); + + return new ConversionResult( + $this->getBaseCurrency(), + $this->responseDate($response), + $this->ratesFromData($response['data'] ?? []), + ); + } + + #[Override] + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + if ($this->getDate() === null) { + throw new ApiException('Date needs to be set!'); + } + + $response = $this->apiRequest('v3/historical', [ + ...$this->buildRateParams(), + 'date' => $this->getDate(), + ]); + + return new ConversionResult( + $this->getBaseCurrency(), + $this->getDate(), + $this->ratesFromData($response['data'] ?? []), + ); + } + + #[Override] + public function convert( + ?float $amount = null, + string|Currency|null $fromCurrency = null, + string|Currency|null $toCurrency = null, + ?DateTimeInterface $date = null, + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($amount !== null) { + $this->amount = $amount; + } + + if ($fromCurrency !== null) { + $this->baseCurrency = Currency::code($fromCurrency); + } + + if ($toCurrency !== null) { + $this->currencies = [Currency::code($toCurrency)]; + } + + $target = $this->currencies[0] ?? null; + if ($target === null) { + throw new ApiException('A target currency is required for convert().'); + } + if ($this->amount === null) { + throw new ApiException('An amount is required for convert().'); + } + + if ($this->getDate() !== null) { + return $this->historical(); + } + + $response = $this->apiRequest('v3/convert', [ + 'value' => $this->amount, + 'base_currency' => $this->getBaseCurrency(), + 'currencies' => $target, + ]); + + $data = $response['data'] ?? []; + $converted = is_array($data) ? ($data['value'] ?? null) : null; + if (!is_scalar($converted)) { + throw new ApiException('CurrencyAPI response did not contain a converted value.'); + } + + $rate = BigDecimal::of((string) $converted) + ->dividedBy(BigDecimal::of((string) $this->amount), ConversionResult::DEFAULT_SCALE, RoundingMode::HALF_UP); + + return new ConversionResult( + $this->getBaseCurrency(), + $this->responseDate($response), + [$target => $rate], + ); + } + + /** + * @return array + */ + private function buildRateParams(): array + { + $params = [ + 'base_currency' => $this->getBaseCurrency(), + ]; + + if ($this->getSymbols() !== []) { + $params['currencies'] = implode(',', $this->getSymbols()); + } + + return $params; + } + + /** + * @param mixed $data + * + * @return array + */ + private function ratesFromData(mixed $data): array + { + if (!is_array($data)) { + throw new ApiException('CurrencyAPI response did not contain rate data.'); + } + + $rates = []; + foreach ($data as $currency => $rateData) { + $value = is_array($rateData) ? ($rateData['value'] ?? null) : null; + if (!$value instanceof BigDecimal && !is_float($value) && !is_int($value) && !is_string($value)) { + throw new ApiException('CurrencyAPI response did not contain a rate for ' . (string) $currency . '.'); + } + + $rates[(string) $currency] = $value; + } + + return $rates; + } + + /** + * @param array $response + */ + private function responseDate(array $response): ?string + { + $meta = $response['meta'] ?? []; + $timestamp = is_array($meta) ? ($meta['last_updated_at'] ?? null) : null; + if (!is_string($timestamp) || $timestamp === '') { + return null; + } + + return DateHelper::format(new DateTimeImmutable($timestamp)); + } +} diff --git a/src/Drivers/CurrencyDriverContract.php b/src/Drivers/CurrencyDriverContract.php index 04ea2fa..d4b89e1 100644 --- a/src/Drivers/CurrencyDriverContract.php +++ b/src/Drivers/CurrencyDriverContract.php @@ -1,138 +1,83 @@ - $symbols + */ + public function currencies(string|Currency|array $symbols = []): static; + + /** + * @param string|Currency|array $symbols + */ + public function to(string|Currency|array $symbols = []): static; + + public function amount(?float $amount): static; + + public function date(?DateTimeInterface $date): static; + + /** + * Returns the date in 'YYYY-mm-dd' format or null if not set. + */ + public function getDate(): ?string; + + /** + * @return list + */ + public function getSymbols(): array; + + /** + * @param string|Currency|array $forCurrency + */ + public function get(string|Currency|array $forCurrency = []): ConversionResult; + + /** + * Converts an amount of `$fromCurrency` into `$toCurrency`, optionally for a given date. + */ + public function convert( + ?float $amount = null, + string|Currency|null $fromCurrency = null, + string|Currency|null $toCurrency = null, + ?DateTimeInterface $date = null, + ): ConversionResult; + + /** + * @param string|Currency|array $forCurrency + */ + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult; + + public function getBaseCurrency(): string; + + public function config(string $key, string $value): static; + + /** + * Sets the API key to use. + * + * Shortcut for config('access_key', $accessKey). + */ + public function accessKey(string $accessKey): static; + + /** + * Switches all HTTP requests to HTTPS. + * + * Drivers default to HTTPS in 2.0; this exists for explicit toggling. + */ + public function secure(): static; + + public function getProtocol(): string; } diff --git a/src/Drivers/CurrencyLayer.php b/src/Drivers/CurrencyLayer.php index c92f18c..c720dfd 100644 --- a/src/Drivers/CurrencyLayer.php +++ b/src/Drivers/CurrencyLayer.php @@ -1,160 +1,118 @@ - 0, - ]; - - /** - * @param string|array $forCurrency - * - * @return ConversionResult - * - * @throws CurrencyException - */ - public function get($forCurrency = []): ConversionResult - { - if (!empty((array)$forCurrency)) { - $this->currencies((array)$forCurrency); - } +declare(strict_types=1); - // Get API response - $response = $this->apiRequest('live', [ - 'source' => $this->getBaseCurrency(), - 'currencies' => join(',', $this->getSymbols()), - ]); +namespace Otherguy\Currency\Drivers; - // Transform rates response - $rates = []; - foreach ($response['quotes'] as $currency => $rate) { - $rates[substr($currency, 3, 3)] = $rate; - } +use Brick\Math\BigDecimal; +use DateTimeImmutable; +use DateTimeInterface; +use Otherguy\Currency\Currency; +use Otherguy\Currency\Exceptions\ApiException; +use Otherguy\Currency\Helpers\DateHelper; +use Otherguy\Currency\Results\ConversionResult; +use Override; - return new ConversionResult($response['source'], $response['timestamp'], $rates); - } - - /** - * @param int|string|DateTime $date - * @param string|array $forCurrency - * - * @return ConversionResult - * - * @throws CurrencyException - */ - public function historical($date = null, $forCurrency = []): ConversionResult - { - // Set date - $this->date($date); - - if (!empty((array)$forCurrency)) { - $this->currencies((array)$forCurrency); - } +class CurrencyLayer extends BaseCurrencyDriver +{ + protected string $apiURL = 'apilayer.net/api'; + protected string $baseCurrency = 'USD'; - if (null === $this->getDate()) { - throw new ApiException('Date needs to be set!'); - } + /** @var array */ + protected array $httpParams = [ + 'format' => 0, + ]; - // Get API response - $response = $this->apiRequest('historical', [ - 'date' => $this->getDate(), - 'source' => $this->getBaseCurrency(), - 'currencies' => join(',', $this->getSymbols()), - ]); - - // Transform rates response - $rates = []; - foreach ($response['quotes'] as $currency => $rate) { - $rates[substr($currency, 3, 3)] = $rate; + #[Override] + public function get(string|Currency|array $forCurrency = []): ConversionResult + { + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + $response = $this->apiRequest('live', [ + 'source' => $this->getBaseCurrency(), + 'currencies' => implode(',', $this->getSymbols()), + ]); + + return new ConversionResult( + $this->responseString($response, 'source', 'CurrencyLayer'), + $this->timestampToDate($this->responseInt($response, 'timestamp', 'CurrencyLayer')), + $this->stripQuotes($this->responseRates($response, 'quotes', 'CurrencyLayer')), + ); } - return new ConversionResult($response['source'], $response['timestamp'], $rates); - } - - /** - * Converts any amount in a given currency to another currency. - * - * @param float $amount The amount to convert. - * @param string $fromCurrency The base currency. - * @param string $toCurrency The target currency. - * @param int|string|DateTime $date The date to get the conversion rate for. - * - * @return float The conversion result. - * - * @throws ApiException - */ - public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float - { - $this->date($date); - - // Overwrite/set params - if ($amount !== null) { - $this->amount = $amount; + #[Override] + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + if ($this->getDate() === null) { + throw new ApiException('Date needs to be set!'); + } + + $response = $this->apiRequest('historical', [ + 'date' => $this->getDate(), + 'source' => $this->getBaseCurrency(), + 'currencies' => implode(',', $this->getSymbols()), + ]); + + return new ConversionResult( + $this->responseString($response, 'source', 'CurrencyLayer'), + $this->timestampToDate($this->responseInt($response, 'timestamp', 'CurrencyLayer')), + $this->stripQuotes($this->responseRates($response, 'quotes', 'CurrencyLayer')), + ); } - if ($fromCurrency !== null) { - $this->baseCurrency = $fromCurrency; + /** + * @param array $params + * + * @return array + */ + #[Override] + protected function apiRequest(string $endpoint, array $params = []): array + { + $response = parent::apiRequest($endpoint, $params); + + if (!($response['success'] ?? false)) { + throw new ApiException( + (string) ($response['error']['info'] ?? 'CurrencyLayer API error'), + (int) ($response['error']['code'] ?? 0), + ); + } + + return $response; } - if ($toCurrency !== null) { - $this->currencies = [$toCurrency]; + /** + * @param array $quotes + * + * @return array + */ + private function stripQuotes(array $quotes): array + { + $rates = []; + foreach ($quotes as $currency => $rate) { + $rates[substr((string) $currency, 3, 3)] = $rate; + } + + return $rates; } - $params = [ - 'from' => $this->getBaseCurrency(), - 'to' => reset($this->currencies), - 'amount' => $this->amount, - ]; + private function timestampToDate(int|string|null $timestamp): ?string + { + if ($timestamp === null) { + return null; + } - if (null !== $this->getDate()) { - $params['date'] = $this->getDate(); + return DateHelper::format(new DateTimeImmutable('@' . $timestamp)); } - - // Get API response - $response = $this->apiRequest('convert', $params); - - // Return the rate as a float - return floatval($response['result']); - } - - /** - * Performs an HTTP request. - * - * @param string $endpoint The API endpoint. - * @param array $params The query parameters for this request. - * @param string $method The HTTP method (defaults to 'GET'). - * - * @return array|bool The response as decoded JSON. - * - * @throws ApiException - */ - function apiRequest(string $endpoint, array $params = [], string $method = 'GET') - { - // Perform actual API request. - $response = parent::apiRequest($endpoint, $params, $method); - - // Handle response exceptions. - if ($response['success'] == false) { - throw new ApiException($response['error']['info'], $response['error']['code']); - } - - return $response; - } } diff --git a/src/Drivers/ExchangeRatesApi.php b/src/Drivers/ExchangeRatesApi.php index 8893a87..b65eb79 100644 --- a/src/Drivers/ExchangeRatesApi.php +++ b/src/Drivers/ExchangeRatesApi.php @@ -1,128 +1,40 @@ -currencies((array)$forCurrency); - } - - // Get API response - $response = $this->apiRequest('latest', [ - 'base' => $this->getBaseCurrency(), - 'symbols' => join(',', $this->getSymbols()), - ]); - - return new ConversionResult($response['base'], $response['date'], $response['rates']); - } - - /** - * @param int|string|DateTime $date - * @param string|array $forCurrency - * - * @return ConversionResult - * - * @throws CurrencyException - */ - public function historical($date = null, $forCurrency = []): ConversionResult - { - // Set date - $this->date($date); +use Otherguy\Currency\Exceptions\ApiException; +use Override; - if (!empty((array)$forCurrency)) { - $this->currencies((array)$forCurrency); - } +class ExchangeRatesApi extends BaseCurrencyDriver +{ + protected string $apiURL = 'api.apilayer.com/exchangerates_data'; + protected string $baseCurrency = 'EUR'; - if (null === $this->getDate()) { - throw new ApiException('Date needs to be set!'); + #[Override] + public function accessKey(string $accessKey): static + { + return $this->config('apikey', $accessKey); } - // Get API response - $response = $this->apiRequest($this->getDate(), [ - 'base' => $this->getBaseCurrency(), - 'symbols' => join(',', $this->getSymbols()), - ]); - - return new ConversionResult($response['base'], $response['date'], $response['rates']); - } - - /** - * Converts any amount in a given currency to another currency. - * - * @param float $amount The amount to convert. - * @param string $fromCurrency The base currency. - * @param string $toCurrency The target currency. - * @param int|string|DateTime $date The date to get the conversion rate for. - * - * @return float The conversion result. - * - * @throws ApiException - */ - public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float - { - throw new ApiException("Endpoint 'convert' is not supported for this driver!", 404); - } - - /** - * Performs an HTTP request. - * - * @param string $endpoint The API endpoint. - * @param array $params The query parameters for this request. - * @param string $method The HTTP method (defaults to 'GET'). - * - * @return array|bool The response as decoded JSON. - * - * @throws ApiException - */ - function apiRequest(string $endpoint, array $params = [], string $method = 'GET') - { - // Perform actual API request. - $response = parent::apiRequest($endpoint, $params, $method); - - - // Handle response exceptions. - if (isset($response['error'])) { - throw new ApiException((string)$response['error'], 500); + /** + * @param array $params + * + * @return array + */ + #[Override] + protected function apiRequest(string $endpoint, array $params = []): array + { + $response = parent::apiRequest($endpoint, $params); + + if (!($response['success'] ?? false)) { + throw new ApiException( + (string) ($response['error']['info'] ?? $response['message'] ?? 'ExchangeRatesApi error'), + (int) ($response['error']['code'] ?? 0), + ); + } + + return $response; } - - return $response; - } } diff --git a/src/Drivers/FastForex.php b/src/Drivers/FastForex.php new file mode 100644 index 0000000..c693aae --- /dev/null +++ b/src/Drivers/FastForex.php @@ -0,0 +1,207 @@ +config('api_key', $accessKey); + } + + #[Override] + public function get(string|Currency|array $forCurrency = []): ConversionResult + { + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + $endpoint = match (count($this->getSymbols())) { + 0 => 'fetch-all', + 1 => 'fetch-one', + default => 'fetch-multi', + }; + + $response = $this->apiRequest($endpoint, $this->buildRateParams()); + + return new ConversionResult( + $this->optionalResponseString($response, 'base') ?? $this->getBaseCurrency(), + $this->responseDate($response), + $this->ratesFromResponse($response), + ); + } + + #[Override] + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + if ($this->getDate() === null) { + throw new ApiException('Date needs to be set!'); + } + + $response = $this->apiRequest('historical', [ + ...$this->buildRateParams(), + 'date' => $this->getDate(), + ]); + + return new ConversionResult( + $this->optionalResponseString($response, 'base') ?? $this->getBaseCurrency(), + $this->optionalResponseString($response, 'date') ?? $this->getDate(), + $this->ratesFromResponse($response), + ); + } + + #[Override] + public function convert( + ?float $amount = null, + string|Currency|null $fromCurrency = null, + string|Currency|null $toCurrency = null, + ?DateTimeInterface $date = null, + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($amount !== null) { + $this->amount = $amount; + } + + if ($fromCurrency !== null) { + $this->baseCurrency = Currency::code($fromCurrency); + } + + if ($toCurrency !== null) { + $this->currencies = [Currency::code($toCurrency)]; + } + + $target = $this->currencies[0] ?? null; + if ($target === null) { + throw new ApiException('A target currency is required for convert().'); + } + if ($this->amount === null) { + throw new ApiException('An amount is required for convert().'); + } + + if ($this->getDate() !== null) { + return $this->historical(); + } + + $response = $this->apiRequest('convert', [ + 'from' => $this->getBaseCurrency(), + 'to' => $target, + 'amount' => $this->amount, + ]); + + $rates = $this->ratesFromResponse($response); + if (!isset($rates[$target])) { + throw new ApiException("fastFOREX response did not contain a conversion for {$target}."); + } + + $rate = BigDecimal::of((string) $rates[$target]) + ->dividedBy(BigDecimal::of((string) $this->amount), ConversionResult::DEFAULT_SCALE, RoundingMode::HALF_UP); + + return new ConversionResult( + $this->optionalResponseString($response, 'base') ?? $this->getBaseCurrency(), + $this->responseDate($response), + [$target => $rate], + ); + } + + /** + * @param array $params + * + * @return array + */ + #[Override] + protected function apiRequest(string $endpoint, array $params = []): array + { + $response = parent::apiRequest($endpoint, $params); + + if (isset($response['error'])) { + throw new ApiException(is_scalar($response['error']) ? (string) $response['error'] : 'fastFOREX API error'); + } + + return $response; + } + + /** + * @return array + */ + private function buildRateParams(): array + { + $params = [ + 'from' => $this->getBaseCurrency(), + ]; + + if ($this->getSymbols() !== []) { + $params['to'] = implode(',', $this->getSymbols()); + } + + return $params; + } + + /** + * @param array $response + * + * @return array + */ + private function ratesFromResponse(array $response): array + { + $rates = $response['result'] ?? $response['results'] ?? null; + if (!is_array($rates)) { + throw new ApiException('fastFOREX response did not contain rate data.'); + } + + $normalised = []; + foreach ($rates as $currency => $rate) { + if (!$rate instanceof BigDecimal && !is_float($rate) && !is_int($rate) && !is_string($rate)) { + throw new ApiException('fastFOREX response did not contain a rate for ' . (string) $currency . '.'); + } + + $normalised[(string) $currency] = $rate; + } + + return $normalised; + } + + /** + * @param array $response + */ + private function responseDate(array $response): ?string + { + if (isset($response['date']) && is_scalar($response['date'])) { + return (string) $response['date']; + } + + if (isset($response['updated']) && is_string($response['updated']) && $response['updated'] !== '') { + return DateHelper::format(new DateTimeImmutable(strtok($response['updated'], ' ') ?: $response['updated'])); + } + + return null; + } +} diff --git a/src/Drivers/FixerIo.php b/src/Drivers/FixerIo.php index b33acb9..cb31730 100644 --- a/src/Drivers/FixerIo.php +++ b/src/Drivers/FixerIo.php @@ -1,152 +1,39 @@ -currencies((array)$forCurrency); - } - - // Get API response - $response = $this->apiRequest('latest', [ - 'base' => $this->getBaseCurrency(), - 'symbols' => join(',', $this->getSymbols()), - ]); - - return new ConversionResult($response['base'], $response['date'], $response['rates']); - } - - /** - * @param int|string|DateTime $date - * @param string|array $forCurrency - * - * @return ConversionResult - * - * @throws CurrencyException - */ - public function historical($date = null, $forCurrency = []): ConversionResult - { - // Set date - $this->date($date); - - if (!empty((array)$forCurrency)) { - $this->currencies((array)$forCurrency); - } - - if (null === $this->getDate()) { - throw new ApiException('Date needs to be set!'); - } - - // Get API response - $response = $this->apiRequest($this->getDate(), [ - 'base' => $this->getBaseCurrency(), - 'symbols' => join(',', $this->getSymbols()), - ]); +declare(strict_types=1); - return new ConversionResult($response['base'], $response['date'], $response['rates']); - } +namespace Otherguy\Currency\Drivers; - /** - * Converts any amount in a given currency to another currency. - * - * @param float $amount The amount to convert. - * @param string $fromCurrency The base currency. - * @param string $toCurrency The target currency. - * @param int|string|DateTime $date The date to get the conversion rate for. - * - * @return float The conversion result. - * - * @throws ApiException - */ - public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float - { - // Set date - $this->date($date); - - // Overwrite/set params - if ($amount !== null) { - $this->amount = $amount; - } - - if ($fromCurrency !== null) { - $this->baseCurrency = $fromCurrency; - } - - if ($toCurrency !== null) { - $this->currencies = [$toCurrency]; - } - - $params = [ - 'from' => $this->getBaseCurrency(), - 'to' => reset($this->currencies), - 'amount' => $this->amount, - ]; - - if (null !== $this->getDate()) { - $params['date'] = $this->getDate(); - } - - // Get API response - $response = $this->apiRequest('convert', $params); - - // Return the rate as a float - return floatval($response['result']); - } - - /** - * Performs an HTTP request. - * - * @param string $endpoint The API endpoint. - * @param array $params The query parameters for this request. - * @param string $method The HTTP method (defaults to 'GET'). - * - * @return array|bool The response as decoded JSON. - * - * @throws ApiException - */ - function apiRequest(string $endpoint, array $params = [], string $method = 'GET') - { - // Perform actual API request. - $response = parent::apiRequest($endpoint, $params, $method); +use Otherguy\Currency\Exceptions\ApiException; +use Override; - // Handle response exceptions. - if ($response['success'] == false) { - $message = ''; - if (isset($response['error']['type'])) { - $message = "[{$response['error']['type']}]"; - } - if (isset($response['error']['info'])) { - $message .= ' ' . $response['error']['info']; - } - throw new ApiException(trim($message), $response['error']['code']); +class FixerIo extends BaseCurrencyDriver +{ + protected string $apiURL = 'data.fixer.io/api'; + protected string $baseCurrency = 'EUR'; + + /** + * @param array $params + * + * @return array + */ + #[Override] + protected function apiRequest(string $endpoint, array $params = []): array + { + $response = parent::apiRequest($endpoint, $params); + + if (!($response['success'] ?? false)) { + $message = ''; + if (isset($response['error']['type'])) { + $message = "[{$response['error']['type']}]"; + } + if (isset($response['error']['info'])) { + $message .= ' ' . $response['error']['info']; + } + + throw new ApiException(trim($message), (int) ($response['error']['code'] ?? 0)); + } + + return $response; } - - return $response; - } } diff --git a/src/Drivers/Frankfurter.php b/src/Drivers/Frankfurter.php new file mode 100644 index 0000000..eaafd11 --- /dev/null +++ b/src/Drivers/Frankfurter.php @@ -0,0 +1,114 @@ +currencies($forCurrency); + } + + $response = $this->apiRequest('latest', $this->buildSymbolsParams()); + + return new ConversionResult( + $this->responseString($response, 'base', 'Frankfurter'), + $this->responseString($response, 'date', 'Frankfurter'), + $this->responseRates($response, 'rates', 'Frankfurter'), + ); + } + + #[Override] + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + if ($this->getDate() === null) { + throw new ApiException('Date needs to be set!'); + } + + $response = $this->apiRequest($this->getDate(), $this->buildSymbolsParams()); + + return new ConversionResult( + $this->responseString($response, 'base', 'Frankfurter'), + $this->responseString($response, 'date', 'Frankfurter'), + $this->responseRates($response, 'rates', 'Frankfurter'), + ); + } + + #[Override] + public function convert( + ?float $amount = null, + string|Currency|null $fromCurrency = null, + string|Currency|null $toCurrency = null, + ?DateTimeInterface $date = null, + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($amount !== null) { + $this->amount = $amount; + } + + if ($fromCurrency !== null) { + $this->baseCurrency = Currency::code($fromCurrency); + } + + if ($toCurrency !== null) { + $this->currencies = [Currency::code($toCurrency)]; + } + + if ($this->currencies === []) { + throw new ApiException('A target currency is required for convert().'); + } + if ($this->amount === null) { + throw new ApiException('An amount is required for convert().'); + } + + return $this->getDate() === null ? $this->get() : $this->historical(); + } + + /** + * @return array + */ + private function buildSymbolsParams(): array + { + $params = [ + 'base' => $this->getBaseCurrency(), + ]; + + if ($this->getSymbols() !== []) { + $params['symbols'] = implode(',', $this->getSymbols()); + } + + return $params; + } +} diff --git a/src/Drivers/MockCurrencyDriver.php b/src/Drivers/MockCurrencyDriver.php index f7484f2..23d4fbb 100644 --- a/src/Drivers/MockCurrencyDriver.php +++ b/src/Drivers/MockCurrencyDriver.php @@ -1,53 +1,97 @@ -getBaseCurrency(), time(), []); - } - - /** - * Converts any amount in a given currency to another currency. - * - * @param float $amount The amount to convert. - * @param string $fromCurrency The base currency. - * @param string $toCurrency The target currency. - * @param int|string|DateTime $date The date to get the conversion rate for. - * - * @return float The conversion result. - */ - public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float - { - return 12.34; - } - - /** - * @param int|string|DateTime $date - * @param string|array $forCurrency - * - * @return ConversionResult - */ - function historical($date = null, $forCurrency = []): ConversionResult - { - return new ConversionResult($this->getBaseCurrency(), time(), []); - } + protected string $apiURL = 'localhost'; + protected string $baseCurrency = 'USD'; + + /** @var array */ + private array $rates = []; + + /** + * @param array $rates + */ + public function withRates(array $rates): self + { + $this->rates = $rates; + + return $this; + } + + #[Override] + public function get(string|Currency|array $forCurrency = []): ConversionResult + { + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + return new ConversionResult( + $this->getBaseCurrency(), + DateHelper::format(DateHelper::today()), + $this->rates, + ); + } + + #[Override] + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + return new ConversionResult( + $this->getBaseCurrency(), + $this->getDate() ?? DateHelper::format(DateHelper::today()), + $this->rates, + ); + } + + #[Override] + public function convert( + ?float $amount = null, + string|Currency|null $fromCurrency = null, + string|Currency|null $toCurrency = null, + ?DateTimeInterface $date = null, + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($amount !== null) { + $this->amount = $amount; + } + + if ($fromCurrency !== null) { + $this->baseCurrency = Currency::code($fromCurrency); + } + + if ($toCurrency !== null) { + $this->currencies = [Currency::code($toCurrency)]; + } + + $target = $this->currencies[0] ?? 'EUR'; + + return new ConversionResult( + $this->getBaseCurrency(), + $this->getDate() ?? DateHelper::format(DateHelper::today()), + [$target => BigDecimal::of('12.34')], + ); + } } diff --git a/src/Drivers/OpenExchangeRates.php b/src/Drivers/OpenExchangeRates.php index 571c07f..c9d857c 100644 --- a/src/Drivers/OpenExchangeRates.php +++ b/src/Drivers/OpenExchangeRates.php @@ -1,162 +1,112 @@ - 'false', - 'show_alternative' => 'true', - ]; - - /** - * Sets the API key to use. OpenExchangeRates uses app_id instead of access_key - * - * Shortcut for config('app_id', $accessKey) - * - * @param string $accessKey Your API key. - * - * @return self - * @see CurrencyDriverContract::config() - * - */ - public function accessKey(string $accessKey): CurrencyDriverContract - { - $this->config('app_id', $accessKey); - return $this; - } - - /** - * @param string|array $forCurrency - * - * @return ConversionResult - * - * @throws CurrencyException - */ - public function get($forCurrency = []): ConversionResult - { - if (!empty((array)$forCurrency)) { - $this->currencies((array)$forCurrency); - } +use Override; - // Get API response - $response = $this->apiRequest('latest.json', [ - 'base' => $this->getBaseCurrency(), - 'symbols' => join(',', $this->getSymbols()), - ]); - - return new ConversionResult($response['base'], $response['timestamp'], $response['rates']); - } - - /** - * @param int|string|DateTime $date - * @param string|array $forCurrency - * - * @return ConversionResult - * - * @throws CurrencyException - */ - public function historical($date = null, $forCurrency = []): ConversionResult - { - // Set date - $this->date($date); - - if (!empty((array)$forCurrency)) { - $this->currencies((array)$forCurrency); - } - - if (null === $this->getDate()) { - throw new ApiException('Date needs to be set!'); +class OpenExchangeRates extends BaseCurrencyDriver +{ + protected string $apiURL = 'openexchangerates.org/api'; + protected string $baseCurrency = 'USD'; + + /** @var array */ + protected array $httpParams = [ + 'prettyprint' => 'false', + 'show_alternative' => 'true', + ]; + + #[Override] + public function accessKey(string $accessKey): static + { + return $this->config('app_id', $accessKey); } - // Get API response - $response = $this->apiRequest("historical/{$this->getDate()}.json", [ - 'base' => $this->getBaseCurrency(), - 'symbols' => join(',', $this->getSymbols()), - ]); - - return new ConversionResult($response['base'], $response['timestamp'], $response['rates']); - } - - /** - * Converts any amount in a given currency to another currency. - * - * @param float $amount The amount to convert. - * @param string $fromCurrency The base currency. - * @param string $toCurrency The target currency. - * @param int|string|DateTime $date The date to get the conversion rate for. - * - * @return float The conversion result. - * - * @throws ApiException - */ - public function convert(float $amount = null, string $fromCurrency = null, string $toCurrency = null, $date = null): float - { - // Set date - $this->date($date); - - // Overwrite/set params - if ($amount !== null) { - $this->amount = $amount; + #[Override] + public function get(string|Currency|array $forCurrency = []): ConversionResult + { + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + $response = $this->apiRequest('latest.json', [ + 'base' => $this->getBaseCurrency(), + 'symbols' => implode(',', $this->getSymbols()), + ]); + + return new ConversionResult( + $this->responseString($response, 'base', 'OpenExchangeRates'), + $this->timestampToDate($this->responseInt($response, 'timestamp', 'OpenExchangeRates')), + $this->responseRates($response, 'rates', 'OpenExchangeRates'), + ); } - if ($fromCurrency !== null) { - $this->baseCurrency = $fromCurrency; + #[Override] + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult { + if ($date instanceof DateTimeInterface) { + $this->date($date); + } + + if ($forCurrency !== []) { + $this->currencies($forCurrency); + } + + if ($this->getDate() === null) { + throw new ApiException('Date needs to be set!'); + } + + $response = $this->apiRequest("historical/{$this->getDate()}.json", [ + 'base' => $this->getBaseCurrency(), + 'symbols' => implode(',', $this->getSymbols()), + ]); + + return new ConversionResult( + $this->responseString($response, 'base', 'OpenExchangeRates'), + $this->timestampToDate($this->responseInt($response, 'timestamp', 'OpenExchangeRates')), + $this->responseRates($response, 'rates', 'OpenExchangeRates'), + ); } - if ($toCurrency !== null) { - $this->currencies = [$toCurrency]; + /** + * @param array $params + * + * @return array + */ + #[Override] + protected function apiRequest(string $endpoint, array $params = []): array + { + $response = parent::apiRequest($endpoint, $params); + + if (($response['error'] ?? false) === true) { + throw new ApiException( + sprintf( + '[%s] %s', + (string) ($response['message'] ?? ''), + (string) ($response['description'] ?? ''), + ), + (int) ($response['status'] ?? 0), + ); + } + + return $response; } - if (null !== $this->getDate()) { - $params['date'] = $this->getDate(); - } + private function timestampToDate(int|string|null $timestamp): ?string + { + if ($timestamp === null) { + return null; + } - $targetCurrency = reset($this->currencies); - - // Get API response - $response = $this->apiRequest("convert/{$this->amount}/{$this->getBaseCurrency()}/{$targetCurrency}"); - - // Return the rate as a float - return floatval($response['response']); - } - - /** - * Performs an HTTP request. - * - * @param string $endpoint The API endpoint. - * @param array $params The query parameters for this request. - * @param string $method The HTTP method (defaults to 'GET'). - * - * @return array|bool The response as decoded JSON. - * - * @throws ApiException - */ - function apiRequest(string $endpoint, array $params = [], string $method = 'GET') - { - // Perform actual API request. - $response = parent::apiRequest($endpoint, $params, $method); - - // Handle response exceptions. - if (isset($response['error']) && $response['error'] == true) { - throw new ApiException("[{$response['message']}] {$response['description']}", $response['status']); + return DateHelper::format(new DateTimeImmutable('@' . $timestamp)); } - - return $response; - } } diff --git a/src/Exceptions/ApiException.php b/src/Exceptions/ApiException.php index 0d3cfb6..8171982 100644 --- a/src/Exceptions/ApiException.php +++ b/src/Exceptions/ApiException.php @@ -1,11 +1,9 @@ -format($format); - } else if ($date === 'now') { - return date($format); - } else if (is_string($date)) { - return (new DateTime($date))->format($format); - } else { - $timestamp = (integer)$date; - return date($format, $timestamp); + public static function format(?DateTimeInterface $date, string $format = 'Y-m-d'): ?string + { + return $date?->format($format); } - } - /** - * Get a date object by given date or time format - * - * Examples:: - * - * Date.create('2018-12-04') - * Date.create('first day of next year') - * - * @param String $time A date/time string. For valid formats see http://php.net/manual/en/datetime.formats.php - * - * @return DateTime - * - * @throws Exception - */ - public static function create(string $time): DateTime - { - return new DateTime($time); - } - - /** - * Get the current date and time - * - * Examples:: - * - * Date.now().timestamp - * - * @return DateTime - * - * @throws Exception - */ - public static function now(): DateTime - { - return new DateTime('now'); - } + public static function now(): DateTimeImmutable + { + return new DateTimeImmutable('now'); + } - /** - * Get the current date - * - * @return DateTime - * - * @throws Exception - */ - public static function today(): DateTime - { - return new DateTime('today'); - } + public static function today(): DateTimeImmutable + { + return new DateTimeImmutable('today'); + } } diff --git a/src/Results/ConversionResult.php b/src/Results/ConversionResult.php index d9f45c2..2398f7d 100644 --- a/src/Results/ConversionResult.php +++ b/src/Results/ConversionResult.php @@ -1,141 +1,173 @@ -originalBaseCurrency = $baseCurrency; - $this->baseCurrency = $baseCurrency; - - $this->date = DateHelper::format($date, 'Y-m-d'); - - $rates[$baseCurrency] = 1.0; - - $this->originalConversionRates = $rates; - $this->conversionRates = $rates; - } - - /** - * Get base currency. - * - * @return string - */ - public function getBaseCurrency(): string - { - return $this->baseCurrency; - } - - /** - * Set new base currency. - * - * @param string $baseCurrency The new base currency. - * - * @return self - * - * @throws CurrencyException - */ - public function setBaseCurrency(string $baseCurrency): ConversionResult - { - if (!isset($this->conversionRates[$baseCurrency])) { - throw new CurrencyException("No conversion result for '$baseCurrency'!"); + public const int DEFAULT_SCALE = 8; + + /** + * @var array + */ + public readonly array $originalConversionRates; + + public readonly string $originalBaseCurrency; + + /** + * @var array + */ + private array $conversionRates; + + private string $baseCurrency; + + /** + * @param array $rates + * + * @throws MathException If a rate value is not a valid numeric. + */ + public function __construct( + string|Currency $baseCurrency, + public readonly ?string $date = null, + array $rates = [], + public readonly int $scale = self::DEFAULT_SCALE, + ) { + $code = Currency::code($baseCurrency); + + $this->originalBaseCurrency = $code; + $this->baseCurrency = $code; + + $normalised = []; + foreach ($rates as $currency => $rate) { + $normalised[(string) $currency] = $this->toBigDecimal($rate); + } + $normalised[$code] = BigDecimal::one(); + + $this->originalConversionRates = $normalised; + $this->conversionRates = $normalised; } - if ($baseCurrency == $this->originalBaseCurrency) { - $this->conversionRates = $this->originalConversionRates; - return $this; + public function getBaseCurrency(): string + { + return $this->baseCurrency; } - // Calculate new conversion rates. - foreach ($this->originalConversionRates as $currency => $rate) { - $this->conversionRates[$currency] = (float)$rate / (float)$this->originalConversionRates[$baseCurrency]; + public function getDate(): ?string + { + return $this->date; } - // Set new base currency. - $this->baseCurrency = $baseCurrency; - $this->conversionRates[$baseCurrency] = 1.0; - - // Return self - return $this; - } - - /** - * Get date. - */ - public function getDate() - { - return $this->date; - } - - /** - * @param string $currency - * - * @return float - * - * @throws CurrencyException - */ - public function rate(string $currency): float - { - if (!isset($this->conversionRates[$currency])) { - throw new CurrencyException("No conversion result for $currency!"); + /** + * @throws CurrencyException + */ + public function setBaseCurrency(string|Currency $baseCurrency): self + { + $code = Currency::code($baseCurrency); + + if (!isset($this->originalConversionRates[$code])) { + throw new CurrencyException("No conversion result for '{$code}'!"); + } + + if ($code === $this->originalBaseCurrency) { + $this->conversionRates = $this->originalConversionRates; + $this->baseCurrency = $code; + + return $this; + } + + $divisor = $this->originalConversionRates[$code]; + + $rebased = []; + foreach ($this->originalConversionRates as $currency => $rate) { + $rebased[$currency] = $rate->dividedBy($divisor, $this->scale, RoundingMode::HALF_UP); + } + $rebased[$code] = BigDecimal::one(); + + $this->conversionRates = $rebased; + $this->baseCurrency = $code; + + return $this; } - return $this->conversionRates[$currency]; - } - - /** - * @param float $amount - * @param string $fromCurrency - * @param string $toCurrency - * - * @return float - * - * @throws CurrencyException - */ - function convert(float $amount, string $fromCurrency, string $toCurrency): float - { - if (!isset($this->conversionRates[$toCurrency])) { - throw new CurrencyException("No conversion result for '$toCurrency'!"); + /** + * @throws CurrencyException + */ + public function rate(string|Currency $currency): BigDecimal + { + $code = Currency::code($currency); + + if (!isset($this->conversionRates[$code])) { + throw new CurrencyException("No conversion result for {$code}!"); + } + + return $this->conversionRates[$code]; + } + + /** + * @throws CurrencyException + */ + public function rateAsFloat(string|Currency $currency): float + { + return $this->rate($currency)->toFloat(); } - if (!isset($this->conversionRates[$fromCurrency])) { - throw new CurrencyException("No conversion result for '$fromCurrency'!"); + /** + * @throws CurrencyException + */ + public function convert( + BigDecimal|float|int|string $amount, + string|Currency $fromCurrency, + string|Currency $toCurrency, + ): BigDecimal { + $from = Currency::code($fromCurrency); + $to = Currency::code($toCurrency); + + if (!isset($this->originalConversionRates[$to])) { + throw new CurrencyException("No conversion result for '{$to}'!"); + } + + if (!isset($this->originalConversionRates[$from])) { + throw new CurrencyException("No conversion result for '{$from}'!"); + } + + return $this->toBigDecimal($amount) + ->multipliedBy($this->originalConversionRates[$to]) + ->dividedBy($this->originalConversionRates[$from], $this->scale, RoundingMode::HALF_UP); } - return $amount * (float)$this->originalConversionRates[$toCurrency] / (float)$this->originalConversionRates[$fromCurrency]; - } + /** + * @return array + */ + public function all(): array + { + return $this->conversionRates; + } + + /** + * @return array + */ + public function allAsFloats(): array + { + $floats = []; + foreach ($this->conversionRates as $code => $rate) { + $floats[$code] = $rate->toFloat(); + } + + return $floats; + } - /** - * @return array - */ - public function all(): array - { - return $this->conversionRates; - } + /** + * @throws MathException + */ + private function toBigDecimal(BigDecimal|float|int|string $value): BigDecimal + { + return $value instanceof BigDecimal ? $value : BigDecimal::of($value); + } } diff --git a/src/Symbol.php b/src/Symbol.php index e168f1b..9679ba9 100644 --- a/src/Symbol.php +++ b/src/Symbol.php @@ -1,382 +1,241 @@ - + * + * @deprecated since 2.0, use Otherguy\Currency\Currency::cases() and ->value + */ + public static function all(): array + { + self::noticeDeprecation(); + + return array_map(static fn (Currency $c): string => $c->value, Currency::cases()); + } + + /** + * @deprecated since 2.0, use Otherguy\Currency\Currency::tryFrom($currency)?->displayName() + */ + public static function name(string $currency): string + { + self::noticeDeprecation(); + + return Currency::tryFrom($currency)?->displayName() ?? ''; + } + + /** + * @return array + * + * @deprecated since 2.0, build via array_combine of Currency::cases() + */ + public static function names(): array + { + self::noticeDeprecation(); + + $names = []; + foreach (Currency::cases() as $case) { + $names[$case->value] = $case->displayName(); + } - protected static $currencyNames = [ - 'AED' => 'United Arab Emirates Dirham', - 'AFN' => 'Afghan Afghani', - 'ALL' => 'Albanian Lek', - 'AMD' => 'Armenian Dram', - 'ANG' => 'Netherlands Antillean Guilder', - 'AOA' => 'Angolan Kwanza', - 'ARS' => 'Argentine Peso', - 'AUD' => 'Australian Dollar', - 'AWG' => 'Aruban Florin', - 'AZN' => 'Azerbaijani Manat', - 'BAM' => 'Bosnia Herzegovina Convertible Mark', - 'BBD' => 'Barbadian Dollar', - 'BDT' => 'Bangladeshi Taka', - 'BGN' => 'Bulgarian Lev', - 'BHD' => 'Bahraini Dinar', - 'BIF' => 'Burundian Franc', - 'BMD' => 'Bermudan Dollar', - 'BND' => 'Brunei Dollar', - 'BOB' => 'Bolivian Boliviano', - 'BRL' => 'Brazilian Real', - 'BSD' => 'Bahamian Dollar', - 'BTC' => 'Bitcoin', - 'BTN' => 'Bhutanese Ngultrum', - 'BWP' => 'Botswanan Pula', - 'BYR' => 'Belarusian Ruble', - 'BYN' => 'New Belarusian Ruble', - 'BZD' => 'Belize Dollar', - 'CAD' => 'Canadian Dollar', - 'CDF' => 'Congolese Franc', - 'CHF' => 'Swiss Franc', - 'CLF' => 'Chilean Unit of Account', - 'CLP' => 'Chilean Peso', - 'CNY' => 'Chinese Yuan', - 'COP' => 'Colombian Peso', - 'CRC' => 'Costa Rican Colón', - 'CUC' => 'Cuban Convertible Peso', - 'CUP' => 'Cuban Peso', - 'CVE' => 'Cape Verdean Escudo', - 'CZK' => 'Czech Republic Koruna', - 'DJF' => 'Djiboutian Franc', - 'DKK' => 'Danish Krone', - 'DOP' => 'Dominican Peso', - 'DZD' => 'Algerian Dinar', - 'EGP' => 'Egyptian Pound', - 'ERN' => 'Eritrean Nakfa', - 'ETB' => 'Ethiopian Birr', - 'EUR' => 'Euro', - 'FJD' => 'Fijian Dollar', - 'FKP' => 'Falkland Islands Pound', - 'GBP' => 'British Pound Sterling', - 'GEL' => 'Georgian Lari', - 'GGP' => 'Guernsey Pound', - 'GHS' => 'Ghanaian Cedi', - 'GIP' => 'Gibraltar Pound', - 'GMD' => 'Gambian Dalasi', - 'GNF' => 'Guinean Franc', - 'GTQ' => 'Guatemalan Quetzal', - 'GYD' => 'Guyanaese Dollar', - 'HKD' => 'Hong Kong Dollar', - 'HNL' => 'Honduran Lempira', - 'HRK' => 'Croatian Kuna', - 'HTG' => 'Haitian Gourde', - 'HUF' => 'Hungarian Forint', - 'IDR' => 'Indonesian Rupiah', - 'ILS' => 'Israeli New Sheqel', - 'IMP' => 'Manx pound', - 'INR' => 'Indian Rupee', - 'IQD' => 'Iraqi Dinar', - 'IRR' => 'Iranian Rial', - 'ISK' => 'Icelandic Króna', - 'JEP' => 'Jersey Pound', - 'JMD' => 'Jamaican Dollar', - 'JOD' => 'Jordanian Dinar', - 'JPY' => 'Japanese Yen', - 'KES' => 'Kenyan Shilling', - 'KGS' => 'Kyrgystani Som', - 'KHR' => 'Cambodian Riel', - 'KMF' => 'Comorian Franc', - 'KPW' => 'North Korean Won', - 'KRW' => 'South Korean Won', - 'KWD' => 'Kuwaiti Dinar', - 'KYD' => 'Cayman Islands Dollar', - 'KZT' => 'Kazakhstani Tenge', - 'LAK' => 'Laotian Kip', - 'LBP' => 'Lebanese Pound', - 'LKR' => 'Sri Lankan Rupee', - 'LRD' => 'Liberian Dollar', - 'LSL' => 'Lesotho Loti', - 'LTL' => 'Lithuanian Litas', - 'LVL' => 'Latvian Lats', - 'LYD' => 'Libyan Dinar', - 'MAD' => 'Moroccan Dirham', - 'MDL' => 'Moldovan Leu', - 'MGA' => 'Malagasy Ariary', - 'MKD' => 'Macedonian Denar', - 'MMK' => 'Myanma Kyat', - 'MNT' => 'Mongolian Tugrik', - 'MOP' => 'Macanese Pataca', - 'MRO' => 'Mauritanian Ouguiya', - 'MUR' => 'Mauritian Rupee', - 'MVR' => 'Maldivian Rufiyaa', - 'MWK' => 'Malawian Kwacha', - 'MXN' => 'Mexican Peso', - 'MYR' => 'Malaysian Ringgit', - 'MZN' => 'Mozambican Metical', - 'NAD' => 'Namibian Dollar', - 'NGN' => 'Nigerian Naira', - 'NIO' => 'Nicaraguan Córdoba', - 'NOK' => 'Norwegian Krone', - 'NPR' => 'Nepalese Rupee', - 'NZD' => 'New Zealand Dollar', - 'OMR' => 'Omani Rial', - 'PAB' => 'Panamanian Balboa', - 'PEN' => 'Peruvian Nuevo Sol', - 'PGK' => 'Papua New Guinean Kina', - 'PHP' => 'Philippine Peso', - 'PKR' => 'Pakistani Rupee', - 'PLN' => 'Polish Zloty', - 'PYG' => 'Paraguayan Guarani', - 'QAR' => 'Qatari Rial', - 'RON' => 'Romanian Leu', - 'RSD' => 'Serbian Dinar', - 'RUB' => 'Russian Ruble', - 'RWF' => 'Rwandan Franc', - 'SAR' => 'Saudi Riyal', - 'SBD' => 'Solomon Islands Dollar', - 'SCR' => 'Seychellois Rupee', - 'SDG' => 'Sudanese Pound', - 'SEK' => 'Swedish Krona', - 'SGD' => 'Singapore Dollar', - 'SHP' => 'Saint Helena Pound', - 'SLL' => 'Sierra Leonean Leone', - 'SOS' => 'Somali Shilling', - 'SRD' => 'Surinamese Dollar', - 'STD' => 'São Tomé and Príncipe Dobra', - 'SVC' => 'Salvadoran Colón', - 'SYP' => 'Syrian Pound', - 'SZL' => 'Swazi Lilangeni', - 'THB' => 'Thai Baht', - 'TJS' => 'Tajikistani Somoni', - 'TMT' => 'Turkmenistani Manat', - 'TND' => 'Tunisian Dinar', - 'TOP' => 'Tongan Paʻanga', - 'TRY' => 'Turkish Lira', - 'TTD' => 'Trinidad and Tobago Dollar', - 'TWD' => 'New Taiwan Dollar', - 'TZS' => 'Tanzanian Shilling', - 'UAH' => 'Ukrainian Hryvnia', - 'UGX' => 'Ugandan Shilling', - 'USD' => 'United States Dollar', - 'UYU' => 'Uruguayan Peso', - 'UZS' => 'Uzbekistan Som', - 'VEF' => 'Venezuelan Bolívar Fuerte', - 'VND' => 'Vietnamese Dong', - 'VUV' => 'Vanuatu Vatu', - 'WST' => 'Samoan Tala', - 'XAF' => 'CFA Franc BEAC', - 'XAG' => 'Silver', - 'XAU' => 'Gold', - 'XCD' => 'East Caribbean Dollar', - 'XDR' => 'Special Drawing Rights', - 'XOF' => 'CFA Franc BCEAO', - 'XPF' => 'CFP Franc', - 'YER' => 'Yemeni Rial', - 'ZAR' => 'South African Rand', - 'ZMW' => 'Zambian Kwacha', - 'ZWL' => 'Zimbabwean Dollar', - ]; + return $names; + } - /** - * @return array - * - * @throws ReflectionException - */ - public static function all(): array - { - return array_values((new ReflectionClass(static::class))->getConstants()); - } + /** + * @internal Test-only helper to re-arm the once-per-process deprecation notice. + */ + public static function resetDeprecationNotice(): void + { + self::$deprecationNoticed = false; + } - /** - * Retrieve the name of a currency by its symbol. - * - * @param mixed $currency The currency symbol. - * - * @return string - */ - public static function name(string $currency): string - { - return isset(static::$currencyNames[$currency]) ? static::$currencyNames[$currency] : null; - } + private static function noticeDeprecation(): void + { + if (self::$deprecationNoticed) { + return; + } - /** - * Retrieve an array of all currencies with their names. - * - * @return array - */ - public static function names(): array - { - return static::$currencyNames; - } + self::$deprecationNoticed = true; + trigger_error( + 'Otherguy\\Currency\\Symbol is deprecated since 2.0. Use Otherguy\\Currency\\Currency instead.', + E_USER_DEPRECATED, + ); + } } diff --git a/tests/CurrencyTest.php b/tests/CurrencyTest.php new file mode 100644 index 0000000..f69fd2d --- /dev/null +++ b/tests/CurrencyTest.php @@ -0,0 +1,48 @@ +assertCount(167, Currency::cases()); + } + + #[Test] + public function backed_value_matches_iso_code(): void + { + $this->assertSame('USD', Currency::USD->value); + $this->assertSame('EUR', Currency::EUR->value); + $this->assertSame('BTC', Currency::BTC->value); + } + + #[Test] + public function display_name_resolves_human_readable_label(): void + { + $this->assertSame('Bitcoin', Currency::BTC->displayName()); + $this->assertSame('Lithuanian Litas', Currency::LTL->displayName()); + $this->assertSame('United States Dollar', Currency::USD->displayName()); + } + + #[Test] + public function try_from_code_returns_matching_case(): void + { + $this->assertSame(Currency::USD, Currency::tryFromCode('USD')); + $this->assertNull(Currency::tryFromCode('XYZ')); + } + + #[Test] + public function code_helper_coerces_strings_and_enum_values(): void + { + $this->assertSame('USD', Currency::code('USD')); + $this->assertSame('USD', Currency::code(Currency::USD)); + } +} diff --git a/tests/DriverFactoryTest.php b/tests/DriverFactoryTest.php index a85e1f6..994c7eb 100644 --- a/tests/DriverFactoryTest.php +++ b/tests/DriverFactoryTest.php @@ -1,33 +1,110 @@ assertInstanceOf(CurrencyDriverContract::class, DriverFactory::make('mock')); - } - - /** @test */ - public function will_throw_exception_if_using_invalid_driver() - { - $this->expectException(DriverNotFoundException::class); - DriverFactory::make('nonexistent-currency-api-driver'); - } - - /** @test */ - public function can_get_list_of_drivers() - { - - $this->assertIsArray(DriverFactory::getDrivers()); - $this->assertArrayHasKey('mock', DriverFactory::getDrivers()); - } + protected function tearDown(): void + { + DriverFactory::setDefault(null); + } + + #[Test] + public function default_factory_can_build_a_driver(): void + { + $this->assertInstanceOf(CurrencyDriverContract::class, DriverFactory::make('mock')); + } + + #[Test] + public function unknown_driver_throws_driver_not_found_exception(): void + { + $this->expectException(DriverNotFoundException::class); + DriverFactory::make('nonexistent-currency-api-driver'); + } + + #[Test] + public function default_drivers_include_built_in_set(): void + { + $drivers = DriverFactory::getDrivers(); + + foreach (['mock', 'fixerio', 'currencylayer', 'openexchangerates', 'exchangeratesapi', 'frankfurter', 'currencyapi', 'fastforex'] as $name) { + $this->assertArrayHasKey($name, $drivers); + } + } + + #[Test] + public function default_factory_can_build_triptally_provider_drivers(): void + { + $factory = new DriverFactory(); + $http = new MockHttpClient(); + + $this->assertInstanceOf( + CurrencyApi::class, + $factory->build('currencyapi', $http, new RequestFactory()), + ); + $this->assertInstanceOf( + FastForex::class, + $factory->build('fastforex', $http, new RequestFactory()), + ); + } + + #[Test] + public function build_accepts_custom_psr18_client_and_psr17_factory(): void + { + $factory = new DriverFactory(); + $http = new MockHttpClient(); + + $driver = $factory->build('frankfurter', $http, new RequestFactory()); + + $this->assertInstanceOf(Frankfurter::class, $driver); + } + + #[Test] + public function register_adds_a_custom_driver_class(): void + { + $factory = new DriverFactory(); + $factory->register('custom-mock', MockCurrencyDriver::class); + + $this->assertArrayHasKey('custom-mock', $factory->drivers()); + $this->assertInstanceOf( + MockCurrencyDriver::class, + $factory->build('custom-mock', new MockHttpClient(), new RequestFactory()), + ); + } + + #[Test] + public function unregister_removes_a_driver(): void + { + $factory = new DriverFactory(); + $factory->register('temp', MockCurrencyDriver::class); + $factory->unregister('temp'); + + $this->assertArrayNotHasKey('temp', $factory->drivers()); + + $this->expectException(DriverNotFoundException::class); + $factory->build('temp', new MockHttpClient(), new RequestFactory()); + } + + #[Test] + public function set_default_replaces_static_singleton(): void + { + $custom = new DriverFactory(['only' => MockCurrencyDriver::class]); + DriverFactory::setDefault($custom); + + $this->assertSame(['only' => MockCurrencyDriver::class], DriverFactory::getDrivers()); + } } diff --git a/tests/Drivers/BaseCurrencyDriverTest.php b/tests/Drivers/BaseCurrencyDriverTest.php index fa8eb36..0ad9b57 100644 --- a/tests/Drivers/BaseCurrencyDriverTest.php +++ b/tests/Drivers/BaseCurrencyDriverTest.php @@ -1,113 +1,135 @@ valid_json_response), - new Response(404, [], '404 - Not Found'), - new Response(200, [], 'Cannot reach upstream currency data server!'), - ]); - - $this->baseCurrencyDriver = DriverFactory::make('mock', new Client(['handler' => HandlerStack::create($mock)])); - } - - /** @test */ - public function init_will_properly_set_parameters() - { - $this->baseCurrencyDriver->source(Symbol::ANG)->currencies([Symbol::DKK, Symbol::USD]); - $this->assertEquals([Symbol::DKK, Symbol::USD], $this->baseCurrencyDriver->getSymbols()); - $this->assertEquals(Symbol::ANG, $this->baseCurrencyDriver->getBaseCurrency()); - } - - /** @test */ - public function will_properly_switch_to_https() - { - $this->assertEquals('http', $this->baseCurrencyDriver->getProtocol()); - $this->assertEquals('https', $this->baseCurrencyDriver->secure()->getProtocol()); - } - - /** @test */ - public function from_sets_base_currency() - { - $this->assertNotEquals(Symbol::BTC, $this->baseCurrencyDriver->getBaseCurrency()); - $this->assertEquals(Symbol::BTC, $this->baseCurrencyDriver->from(Symbol::BTC)->getBaseCurrency()); - } - - /** @test */ - public function to_sets_target_currency() - { - $this->assertIsArray($this->baseCurrencyDriver->getSymbols()); - $this->assertCount(2, $this->baseCurrencyDriver->to([Symbol::BTC, Symbol::LTL])->getSymbols()); - $this->assertEquals([Symbol::BTC, Symbol::LTL], $this->baseCurrencyDriver->to([Symbol::BTC, Symbol::LTL])->getSymbols()); - } - - /** @test */ - public function setters_are_fluent() - { - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->source(Symbol::BTC)); - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->from(Symbol::BTC)); - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->amount(12)); - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->to(Symbol::LTL)); - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->currencies(Symbol::LTL)); - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->secure()); - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->config('test', 'value')); - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->accessKey('access key')); - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver->date(time())); - } - - /** @test */ - public function can_set_and_retrieve_date() - { - $this->assertEquals('2019-06-11', $this->baseCurrencyDriver->date(1560293762)->getDate()); - $this->assertEquals('2019-06-11', $this->baseCurrencyDriver->date('2019-06-11')->getDate()); - $this->assertEquals('2019-06-11', $this->baseCurrencyDriver->date(DateTime::createFromFormat('d.m.Y', '11.6.2019'))->getDate()); - } - - /** @test */ - public function can_perform_api_requests() - { - $response = $this->baseCurrencyDriver->apiRequest('test'); - - $this->assertIsArray($response); - $this->assertIsArray($response['rates']); - $this->assertEquals('2019-06-11', $response['date']); - $this->assertEquals(Symbol::USD, $response['base']); - - try { - $this->baseCurrencyDriver->apiRequest('fail'); - } catch (ApiException $exception) { - $this->assertInstanceOf(ApiException::class, $exception); - $this->assertEquals(404, $exception->getCode()); - $this->assertInstanceOf(MockCurrencyDriver::class, $this->baseCurrencyDriver); + private MockHttpClient $http; + private TestableDriver $driver; + + protected function setUp(): void + { + $this->http = new MockHttpClient(); + $this->driver = new TestableDriver($this->http, new RequestFactory()); + } + + #[Test] + public function init_will_properly_set_parameters(): void + { + $this->driver->source(Currency::ANG)->currencies([Currency::DKK, Currency::USD]); + + $this->assertSame(['DKK', 'USD'], $this->driver->getSymbols()); + $this->assertSame('ANG', $this->driver->getBaseCurrency()); + } + + #[Test] + public function defaults_to_https_protocol(): void + { + $this->assertSame('https', $this->driver->getProtocol()); + $this->assertSame('https', $this->driver->secure()->getProtocol()); + } + + #[Test] + public function from_sets_base_currency(): void + { + $this->assertNotSame('BTC', $this->driver->getBaseCurrency()); + $this->assertSame('BTC', $this->driver->from(Currency::BTC)->getBaseCurrency()); + } + + #[Test] + public function to_sets_target_currencies(): void + { + $this->assertSame( + ['BTC', 'LTL'], + $this->driver->to([Currency::BTC, Currency::LTL])->getSymbols(), + ); + } + + #[Test] + public function setters_are_fluent(): void + { + $factory = new RequestFactory(); + $base = new MockCurrencyDriver($this->http, $factory); + + $this->assertInstanceOf(MockCurrencyDriver::class, $base->source(Currency::BTC)); + $this->assertInstanceOf(MockCurrencyDriver::class, $base->from(Currency::BTC)); + $this->assertInstanceOf(MockCurrencyDriver::class, $base->amount(12.0)); + $this->assertInstanceOf(MockCurrencyDriver::class, $base->to(Currency::LTL)); + $this->assertInstanceOf(MockCurrencyDriver::class, $base->currencies(Currency::LTL)); + $this->assertInstanceOf(MockCurrencyDriver::class, $base->secure()); + $this->assertInstanceOf(MockCurrencyDriver::class, $base->config('test', 'value')); + $this->assertInstanceOf(MockCurrencyDriver::class, $base->accessKey('access key')); + $this->assertInstanceOf(MockCurrencyDriver::class, $base->date(new DateTimeImmutable('2019-06-11'))); + } + + #[Test] + public function date_setter_formats_to_iso_date(): void + { + $this->driver->date(new DateTimeImmutable('@1560293762')); + $this->assertSame('2019-06-11', $this->driver->getDate()); + + $this->driver->date(new DateTimeImmutable('2019-06-11')); + $this->assertSame('2019-06-11', $this->driver->getDate()); + + $this->driver->date(null); + $this->assertNull($this->driver->getDate()); + } + + #[Test] + public function api_request_decodes_json_response_body(): void + { + $this->http->enqueue(new Response(200, [], '{"success":true,"base":"USD","date":"2019-06-11","rates":{"JPY":107.346001,"EUR":0.813399}}')); + + /** @var array{base: string, date: string, rates: array} $response */ + $response = $this->driver->callApi('latest'); + + $this->assertSame('USD', $response['base']); + $this->assertSame('2019-06-11', $response['date']); + $this->assertSame(107.346001, $response['rates']['JPY']); + } + + #[Test] + public function api_request_wraps_invalid_json_in_api_exception(): void + { + $this->http->enqueue(new Response(200, [], 'Cannot reach upstream currency data server!')); + + $this->expectException(ApiException::class); + $this->driver->callApi('nojson'); + } + + #[Test] + public function api_request_includes_http_params_in_query_string(): void + { + $this->http->enqueue(new Response(200, [], '{"ok":true}')); + + $this->driver->config('access_key', 'secret'); + $this->driver->callApi('latest', ['base' => 'USD']); + + $uri = (string) $this->http->lastRequest()?->getUri(); + $this->assertStringContainsString('access_key=secret', $uri); + $this->assertStringContainsString('base=USD', $uri); + $this->assertStringStartsWith('https://example.test/latest?', $uri); } - try { - $this->baseCurrencyDriver->apiRequest('nojson'); - } catch (ApiException $exception) { - $this->assertInstanceOf(ApiException::class, $exception); - $this->assertEquals(JSON_ERROR_SYNTAX, $exception->getCode()); - $this->assertEquals('Syntax error', $exception->getMessage()); + #[Test] + public function api_request_omits_query_string_when_no_params(): void + { + $this->http->enqueue(new Response(200, [], '{"ok":true}')); + + $this->driver->callApi('ping'); + + $this->assertSame('https://example.test/ping', (string) $this->http->lastRequest()?->getUri()); } - } } diff --git a/tests/Drivers/CurrencyApiTest.php b/tests/Drivers/CurrencyApiTest.php new file mode 100644 index 0000000..9cc579a --- /dev/null +++ b/tests/Drivers/CurrencyApiTest.php @@ -0,0 +1,126 @@ +harness = new DriverHarness(); + $driver = $this->harness->make('currencyapi'); + $this->assertInstanceOf(CurrencyApi::class, $driver); + $this->driver = $driver; + } + + #[Test] + public function access_key_is_sent_as_apikey_header(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-11T10:15:59Z"},"data":{"EUR":{"code":"EUR","value":0.8601}}}')); + + $this->driver->accessKey('currencyapi-token')->from(Currency::USD)->get([Currency::EUR]); + + $request = $this->harness->http->lastRequest(); + $this->assertNotNull($request); + $this->assertSame(['currencyapi-token'], $request->getHeader('apikey')); + $this->assertStringNotContainsString('apikey=', (string) $request->getUri()); + } + + #[Test] + public function can_get_latest_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-11T10:15:59Z"},"data":{"EUR":{"code":"EUR","value":0.8601},"GBP":{"code":"GBP","value":0.7462}}}')); + + $result = $this->driver->from(Currency::USD)->get([Currency::EUR, Currency::GBP]); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2025-10-11', $result->getDate()); + $this->assertSame('0.8601', (string) $result->rate(Currency::EUR)); + $this->assertSame('0.7462', (string) $result->rate(Currency::GBP)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.currencyapi.com/v3/latest', $uri); + $this->assertStringContainsString('base_currency=USD', $uri); + $this->assertStringContainsString('currencies=EUR%2CGBP', $uri); + } + + #[Test] + public function can_get_historical_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-10T23:59:59Z"},"data":{"EUR":{"code":"EUR","value":0.8601}}}')); + + $result = $this->driver->from(Currency::USD)->historical(new DateTimeImmutable('2025-10-10'), [Currency::EUR]); + + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2025-10-10', $result->getDate()); + $this->assertSame('0.8601', (string) $result->rate(Currency::EUR)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.currencyapi.com/v3/historical', $uri); + $this->assertStringContainsString('date=2025-10-10', $uri); + } + + #[Test] + public function can_convert_currency_amounts(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-11T10:15:59Z"},"data":{"value":86.01}}')); + + $result = $this->driver->convert(100.0, Currency::USD, Currency::EUR); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2025-10-11', $result->getDate()); + $this->assertEqualsWithDelta(0.8601, $result->rateAsFloat(Currency::EUR), 0.000001); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.currencyapi.com/v3/convert', $uri); + $this->assertStringContainsString('value=100', $uri); + $this->assertStringContainsString('base_currency=USD', $uri); + $this->assertStringContainsString('currencies=EUR', $uri); + } + + #[Test] + public function convert_uses_historical_rates_when_date_given(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"meta":{"last_updated_at":"2025-10-10T23:59:59Z"},"data":{"EUR":{"code":"EUR","value":0.8601}}}')); + + $result = $this->driver->convert(100.0, Currency::USD, Currency::EUR, new DateTimeImmutable('2025-10-10')); + + $this->assertSame('2025-10-10', $result->getDate()); + $this->assertSame('0.8601', (string) $result->rate(Currency::EUR)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('/v3/historical', $uri); + } + + #[Test] + public function fails_to_get_historical_rates_if_date_not_set(): void + { + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->historical(); + } + + #[Test] + public function can_handle_response_failures(): void + { + $this->harness->http->enqueue(new Response(422, [], '{"message":"Validation error","errors":{"currencies":["The selected currencies is invalid."]}}')); + + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to('ZZZ')->get(); + } +} diff --git a/tests/Drivers/CurrencyLayerTest.php b/tests/Drivers/CurrencyLayerTest.php index db3b792..a133589 100644 --- a/tests/Drivers/CurrencyLayerTest.php +++ b/tests/Drivers/CurrencyLayerTest.php @@ -1,96 +1,102 @@ mockHandler = new MockHandler(); - $this->currencyLayer = DriverFactory::make('currencylayer', new Client(['handler' => $this->mockHandler])); - } - - /** @test */ - public function can_get_latest_rates() - { - // Response from https://currencylayer.com/documentation - $this->mockHandler->append(new Response(200, [], '{"success":true,"terms":"https://currencylayer.com/terms","privacy":"https://currencylayer.com/privacy","timestamp":1432400348,"source":"USD","quotes":{"USDAUD":1.278342,"USDEUR":1.278342,"USDGBP":0.908019,"USDPLN":3.731504}}')); - - $result = $this->currencyLayer->from(Symbol::USD)->get([Symbol::AUD, Symbol::EUR, Symbol::GBP, Symbol::PLN]); - - $this->assertInstanceOf(ConversionResult::class, $result); - - $this->assertEquals(Symbol::USD, $result->getBaseCurrency()); - $this->assertEquals('2015-05-23', $result->getDate()); - - $this->assertEquals(1.278342, $result->rate(Symbol::AUD)); - $this->assertEquals(1.278342, $result->rate(Symbol::EUR)); - $this->assertEquals(0.908019, $result->rate(Symbol::GBP)); - $this->assertEquals(3.731504, $result->rate(Symbol::PLN)); - } - - - /** @test */ - public function can_get_historical_rates() - { - // Response from https://currencylayer.com/documentation - $this->mockHandler->append(new Response(200, [], '{"success":true,"terms":"https://currencylayer.com/terms","privacy":"https://currencylayer.com/privacy","historical":true,"date":"2005-02-01","timestamp":1107302399,"source":"USD","quotes":{"USDAED":3.67266,"USDALL":96.848753,"USDAMD":475.798297,"USDANG":1.790403,"USDARS":2.918969,"USDAUD":1.293878}}')); - - $result = $this->currencyLayer->from(Symbol::USD)->historical('2005-02-01', [Symbol::AED, Symbol::ALL, Symbol::AMD, Symbol::ANG, Symbol::ARS, Symbol::AUD]); - - $this->assertInstanceOf(ConversionResult::class, $result); - - $this->assertEquals(Symbol::USD, $result->getBaseCurrency()); - $this->assertEquals('2005-02-01', $result->getDate()); - - $this->assertEquals(3.67266, $result->rate(Symbol::AED)); - $this->assertEquals(96.848753, $result->rate(Symbol::ALL)); - $this->assertEquals(475.798297, $result->rate(Symbol::AMD)); - $this->assertEquals(1.790403, $result->rate(Symbol::ANG)); - $this->assertEquals(2.918969, $result->rate(Symbol::ARS)); - $this->assertEquals(1.293878, $result->rate(Symbol::AUD)); - } - - /** @test */ - public function fails_to_get_historical_rates_if_date_not_set() - { - $this->expectException(ApiException::class); - $this->currencyLayer->from(Symbol::USD)->to(Symbol::EUR)->historical(); - } - - /** @test */ - public function can_convert_currency_amounts() - { - // Response from https://currencylayer.com/documentation - $this->mockHandler->append(new Response(200, [], '{"success":true,"terms":"https://currencylayer.com/terms","privacy":"https://currencylayer.com/privacy","query":{"from":"USD","to":"GBP","amount":10},"info":{"timestamp":1430068515,"quote":0.658443},"result":6.58443}')); - - $result = $this->currencyLayer->convert(10, Symbol::USD, Symbol::GBP, 1430068515); - $this->assertEquals(6.58443, $result); - } - - /** @test */ - public function can_handle_response_failures() - { - // Response from https://currencylayer.com/documentation - $this->mockHandler->append(new Response(200, [], '{"success":false,"error":{"code":104,"info":"Your monthly usage limit has been reached. Please upgrade your subscription plan."}}')); - - $this->expectException(ApiException::class); - $this->currencyLayer->from(Symbol::USD)->to(Symbol::LTL)->get(); - } + private DriverHarness $harness; + private CurrencyLayer $driver; + + protected function setUp(): void + { + $this->harness = new DriverHarness(); + $driver = $this->harness->make('currencylayer'); + $this->assertInstanceOf(CurrencyLayer::class, $driver); + $this->driver = $driver; + } + + #[Test] + public function can_get_latest_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"timestamp":1432400348,"source":"USD","quotes":{"USDAUD":1.278342,"USDEUR":1.278342,"USDGBP":0.908019,"USDPLN":3.731504}}')); + + $result = $this->driver->from(Currency::USD)->get([Currency::AUD, Currency::EUR, Currency::GBP, Currency::PLN]); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2015-05-23', $result->getDate()); + + $this->assertSame('1.278342', (string) $result->rate(Currency::AUD)); + $this->assertSame('1.278342', (string) $result->rate(Currency::EUR)); + $this->assertSame('0.908019', (string) $result->rate(Currency::GBP)); + $this->assertSame('3.731504', (string) $result->rate(Currency::PLN)); + } + + #[Test] + public function can_get_historical_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"historical":true,"date":"2005-02-01","timestamp":1107302399,"source":"USD","quotes":{"USDAED":3.67266,"USDAUD":1.293878}}')); + + $result = $this->driver->from(Currency::USD)->historical( + new DateTimeImmutable('2005-02-01'), + [Currency::AED, Currency::AUD], + ); + + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2005-02-01', $result->getDate()); + $this->assertSame('3.67266', (string) $result->rate(Currency::AED)); + $this->assertSame('1.293878', (string) $result->rate(Currency::AUD)); + } + + #[Test] + public function fails_to_get_historical_rates_if_date_not_set(): void + { + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->historical(); + } + + #[Test] + public function can_convert_currency_amounts(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"query":{"from":"USD","to":"GBP","amount":10},"info":{"timestamp":1430068515,"quote":0.658443},"result":6.58443}')); + + $result = $this->driver->convert(10.0, Currency::USD, Currency::GBP); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertEqualsWithDelta(0.658443, $result->rateAsFloat(Currency::GBP), 0.000001); + } + + #[Test] + public function can_handle_response_failures(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":false,"error":{"code":104,"info":"Your monthly usage limit has been reached. Please upgrade your subscription plan."}}')); + + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->get(); + } + + #[Test] + public function access_key_is_added_to_request_query_string(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"timestamp":1700000000,"source":"USD","quotes":{"USDEUR":0.9}}')); + + $this->driver->accessKey('cl-key')->from(Currency::USD)->get([Currency::EUR]); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('access_key=cl-key', $uri); + $this->assertStringContainsString('source=USD', $uri); + $this->assertStringContainsString('currencies=EUR', $uri); + } } diff --git a/tests/Drivers/ExchangeRatesApiTest.php b/tests/Drivers/ExchangeRatesApiTest.php index f5180a2..ee2d343 100644 --- a/tests/Drivers/ExchangeRatesApiTest.php +++ b/tests/Drivers/ExchangeRatesApiTest.php @@ -1,104 +1,98 @@ mockHandler = new MockHandler(); - $this->exchangeRatesApi = DriverFactory::make('exchangeratesapi', new Client(['handler' => $this->mockHandler])); - } - - /** @test */ - public function fails_to_set_api_key() - { - $this->expectException(ApiException::class); - $this->expectExceptionMessage('No Access Key is required for this driver!'); - $this->expectExceptionCode(400); - $this->exchangeRatesApi->accessKey('test-access-key'); - } - - /** @test */ - public function can_get_latest_rates() - { - // Response from https://exchangeratesapi.io - $this->mockHandler->append(new Response(200, [], '{"base":"EUR","rates":{"NOK":9.772,"USD":1.1289,"JPY":122.44},"date":"2019-06-13"}')); - - $result = $this->exchangeRatesApi->from(Symbol::EUR)->get([Symbol::NOK, Symbol::JPY, Symbol::USD]); - - $this->assertInstanceOf(ConversionResult::class, $result); - - $this->assertEquals(Symbol::EUR, $result->getBaseCurrency()); - $this->assertEquals('2019-06-13', $result->getDate()); - $this->assertEquals(9.772, $result->rate(Symbol::NOK)); - $this->assertEquals(1.1289, $result->rate(Symbol::USD)); - $this->assertEquals(122.44, $result->rate(Symbol::JPY)); - } - - - /** @test */ - public function can_get_historical_rates() - { - // Response from https://exchangeratesapi.io - $this->mockHandler->append(new Response(200, [], '{"base":"GBP","rates":{"NOK":10.088752796,"CAD":1.7366601677,"USD":1.636783369,"JPY":170.6398095762,"EUR":1.1961293255},"date":"2013-12-24"}')); - - $result = $this->exchangeRatesApi->from(Symbol::GBP)->historical('2013-12-24', [Symbol::NOK, Symbol::CAD, Symbol::USD, Symbol::JPY, Symbol::EUR]); - - $this->assertInstanceOf(ConversionResult::class, $result); - - $this->assertEquals(Symbol::GBP, $result->getBaseCurrency()); - $this->assertEquals('2013-12-24', $result->getDate()); - - $this->assertEquals(1.636783369, $result->rate(Symbol::USD)); - $this->assertEquals(1.1961293255, $result->rate(Symbol::EUR)); - $this->assertEquals(1.7366601677, $result->rate(Symbol::CAD)); - $this->assertEquals(10.088752796, $result->rate(Symbol::NOK)); - $this->assertEquals(170.6398095762, $result->rate(Symbol::JPY)); - } - - /** @test */ - public function fails_to_get_historical_rates_if_date_not_set() - { - $this->expectException(ApiException::class); - $this->exchangeRatesApi->from(Symbol::USD)->to(Symbol::EUR)->historical(); - } - - /** @test */ - public function fails_to_convert_currency_amounts() - { - $this->expectException(ApiException::class); - $this->expectExceptionMessage("Endpoint 'convert' is not supported for this driver!"); - $this->expectExceptionCode(404); - - $result = $this->exchangeRatesApi->convert(25, Symbol::GBP, Symbol::JPY, '2018-02-22'); - } - - /** @test */ - public function can_handle_response_failures() - { - // Response from https://exchangeratesapi.io - $this->mockHandler->append(new Response(200, [], '{"error":"Symbols \'USD,CAD,EUR,JPY,NOK,CDP\' are invalid for date 2019-06-14."}')); - - $this->expectException(ApiException::class); - $this->expectExceptionCode(500); - $this->exchangeRatesApi->from(Symbol::USD)->to(Symbol::LTL)->get(); - } + private DriverHarness $harness; + private ExchangeRatesApi $driver; + + protected function setUp(): void + { + $this->harness = new DriverHarness(); + $driver = $this->harness->make('exchangeratesapi'); + $this->assertInstanceOf(ExchangeRatesApi::class, $driver); + $this->driver = $driver; + } + + #[Test] + public function access_key_is_sent_as_apikey_query_param(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"base":"EUR","date":"2024-01-01","rates":{"USD":1.1}}')); + + $this->driver->accessKey('apilayer-token')->get([Currency::USD]); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('apikey=apilayer-token', $uri); + } + + #[Test] + public function can_get_latest_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"base":"EUR","rates":{"NOK":9.772,"USD":1.1289,"JPY":122.44},"date":"2019-06-13"}')); + + $result = $this->driver->from(Currency::EUR)->get([Currency::NOK, Currency::JPY, Currency::USD]); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('EUR', $result->getBaseCurrency()); + $this->assertSame('2019-06-13', $result->getDate()); + $this->assertSame('9.772', (string) $result->rate(Currency::NOK)); + $this->assertSame('1.1289', (string) $result->rate(Currency::USD)); + $this->assertSame('122.44', (string) $result->rate(Currency::JPY)); + } + + #[Test] + public function can_get_historical_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"base":"GBP","rates":{"NOK":10.088752796,"CAD":1.7366601677,"USD":1.636783369,"JPY":170.6398095762,"EUR":1.1961293255},"date":"2013-12-24"}')); + + $result = $this->driver->from(Currency::GBP)->historical( + new DateTimeImmutable('2013-12-24'), + [Currency::USD, Currency::EUR, Currency::CAD, Currency::JPY, Currency::NOK], + ); + + $this->assertSame('GBP', $result->getBaseCurrency()); + $this->assertSame('2013-12-24', $result->getDate()); + $this->assertSame('1.636783369', (string) $result->rate(Currency::USD)); + $this->assertSame('1.1961293255', (string) $result->rate(Currency::EUR)); + } + + #[Test] + public function fails_to_get_historical_rates_if_date_not_set(): void + { + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->historical(); + } + + #[Test] + public function can_convert_currency_amounts(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"query":{"from":"GBP","to":"JPY","amount":25},"info":{"timestamp":1519328414,"rate":148.972231},"date":"2018-02-22","result":3724.305775}')); + + $result = $this->driver->convert(25.0, Currency::GBP, Currency::JPY, new DateTimeImmutable('2018-02-22')); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertEqualsWithDelta(148.972231, $result->rateAsFloat(Currency::JPY), 0.000001); + } + + #[Test] + public function can_handle_response_failures(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":false,"error":{"code":101,"info":"Invalid API key"}}')); + + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->get(); + } } diff --git a/tests/Drivers/FastForexTest.php b/tests/Drivers/FastForexTest.php new file mode 100644 index 0000000..d038e2b --- /dev/null +++ b/tests/Drivers/FastForexTest.php @@ -0,0 +1,152 @@ +harness = new DriverHarness(); + $driver = $this->harness->make('fastforex'); + $this->assertInstanceOf(FastForex::class, $driver); + $this->driver = $driver; + } + + #[Test] + public function access_key_is_added_to_request_query_string(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","result":{"EUR":0.8601},"updated":"2025-10-10 23:59:07","ms":13}')); + + $this->driver->accessKey('fastforex-token')->from(Currency::USD)->get(Currency::EUR); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('api_key=fastforex-token', $uri); + } + + #[Test] + public function can_get_latest_rate_for_one_currency(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","result":{"EUR":0.8601},"updated":"2025-10-10 23:59:07","ms":13}')); + + $result = $this->driver->from(Currency::USD)->get(Currency::EUR); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2025-10-10', $result->getDate()); + $this->assertSame('0.8601', (string) $result->rate(Currency::EUR)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.fastforex.io/fetch-one', $uri); + $this->assertStringContainsString('from=USD', $uri); + $this->assertStringContainsString('to=EUR', $uri); + } + + #[Test] + public function can_get_latest_rates_for_multiple_currencies(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","results":{"EUR":0.8601,"GBP":0.7462},"updated":"2025-10-10 23:59:07","ms":13}')); + + $result = $this->driver->from(Currency::USD)->get([Currency::EUR, Currency::GBP]); + + $this->assertSame('0.8601', (string) $result->rate(Currency::EUR)); + $this->assertSame('0.7462', (string) $result->rate(Currency::GBP)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.fastforex.io/fetch-multi', $uri); + $this->assertStringContainsString('to=EUR%2CGBP', $uri); + } + + #[Test] + public function can_get_latest_rates_without_symbols(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","results":{"EUR":0.8601},"updated":"2025-10-10 23:59:07","ms":13}')); + + $result = $this->driver->from(Currency::USD)->get(); + + $this->assertSame('0.8601', (string) $result->rate(Currency::EUR)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.fastforex.io/fetch-all', $uri); + $this->assertStringContainsString('from=USD', $uri); + $this->assertStringNotContainsString('to=', $uri); + } + + #[Test] + public function can_get_historical_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"date":"2025-10-10","base":"USD","results":{"EUR":0.8601},"ms":2}')); + + $result = $this->driver->from(Currency::USD)->historical(new DateTimeImmutable('2025-10-10'), Currency::EUR); + + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2025-10-10', $result->getDate()); + $this->assertSame('0.8601', (string) $result->rate(Currency::EUR)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.fastforex.io/historical', $uri); + $this->assertStringContainsString('date=2025-10-10', $uri); + } + + #[Test] + public function can_convert_currency_amounts(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","amount":100,"result":{"EUR":86.01},"ms":13}')); + + $result = $this->driver->convert(100.0, Currency::USD, Currency::EUR); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertEqualsWithDelta(0.8601, $result->rateAsFloat(Currency::EUR), 0.000001); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.fastforex.io/convert', $uri); + $this->assertStringContainsString('from=USD', $uri); + $this->assertStringContainsString('to=EUR', $uri); + $this->assertStringContainsString('amount=100', $uri); + } + + #[Test] + public function convert_uses_historical_rates_when_date_given(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"date":"2025-10-10","base":"USD","results":{"EUR":0.8601},"ms":2}')); + + $result = $this->driver->convert(100.0, Currency::USD, Currency::EUR, new DateTimeImmutable('2025-10-10')); + + $this->assertSame('2025-10-10', $result->getDate()); + $this->assertSame('0.8601', (string) $result->rate(Currency::EUR)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('/historical', $uri); + } + + #[Test] + public function fails_to_get_historical_rates_if_date_not_set(): void + { + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->historical(); + } + + #[Test] + public function can_handle_response_failures(): void + { + $this->harness->http->enqueue(new Response(401, [], '{"error":"Invalid API key"}')); + + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->get(); + } +} diff --git a/tests/Drivers/FixerIoTest.php b/tests/Drivers/FixerIoTest.php index 9633e30..ec54ac9 100644 --- a/tests/Drivers/FixerIoTest.php +++ b/tests/Drivers/FixerIoTest.php @@ -1,91 +1,118 @@ mockHandler = new MockHandler(); - $this->fixerIo = DriverFactory::make('fixerio', new Client(['handler' => $this->mockHandler])); - } - - /** @test */ - public function can_get_latest_rates() - { - // Response from https://fixer.io/documentation - $this->mockHandler->append(new Response(200, [], '{ "success": true, "timestamp": 1519296206, "base": "USD", "date": "2018-02-22", "rates": { "GBP": 0.72007, "JPY": 107.346001, "EUR": 0.813399 } }')); - - $result = $this->fixerIo->from(Symbol::USD)->get([Symbol::GBP, Symbol::JPY, Symbol::EUR]); - - $this->assertInstanceOf(ConversionResult::class, $result); - - $this->assertEquals(Symbol::USD, $result->getBaseCurrency()); - $this->assertEquals('2018-02-22', $result->getDate()); - $this->assertEquals(0.72007, $result->rate(Symbol::GBP)); - $this->assertEquals(107.346001, $result->rate(Symbol::JPY)); - $this->assertEquals(0.813399, $result->rate(Symbol::EUR)); - } - - - /** @test */ - public function can_get_historical_rates() - { - // Response from https://fixer.io/documentation - $this->mockHandler->append(new Response(200, [], '{ "success": true, "historical": true, "date": "2013-12-24", "timestamp": 1387929599, "base": "GBP", "rates": { "USD": 1.636492, "EUR": 1.196476, "CAD": 1.739516 } }')); - - $result = $this->fixerIo->from(Symbol::GBP)->historical('2013-12-24', [Symbol::USD, Symbol::EUR, Symbol::CAD]); - - $this->assertInstanceOf(ConversionResult::class, $result); - - $this->assertEquals(Symbol::GBP, $result->getBaseCurrency()); - $this->assertEquals('2013-12-24', $result->getDate()); - - $this->assertEquals(1.636492, $result->rate(Symbol::USD)); - $this->assertEquals(1.196476, $result->rate(Symbol::EUR)); - $this->assertEquals(1.739516, $result->rate(Symbol::CAD)); - } - - /** @test */ - public function fails_to_get_historical_rates_if_date_not_set() - { - $this->expectException(ApiException::class); - $this->fixerIo->from(Symbol::USD)->to(Symbol::EUR)->historical(); - } - - /** @test */ - public function can_convert_currency_amounts() - { - // Response from https://fixer.io/documentation - $this->mockHandler->append(new Response(200, [], '{ "success": true, "query": { "from": "GBP", "to": "JPY", "amount": 25 }, "info": { "timestamp": 1519328414, "rate": 148.972231 }, "historical": "true", "date": "2018-02-22", "result": 3724.305775 }')); - - $result = $this->fixerIo->convert(25, Symbol::GBP, Symbol::JPY, '2018-02-22'); - $this->assertEquals(3724.305775, $result); - } - - /** @test */ - public function can_handle_response_failures() - { - // Response from https://fixer.io/documentation - $this->mockHandler->append(new Response(200, [], '{ "success": false, "error": { "code": 104, "type": "api_volume_reached", "info": "Your monthly API request volume has been reached. Please upgrade your plan." } }')); - - $this->expectException(ApiException::class); - $this->fixerIo->from(Symbol::USD)->to(Symbol::LTL)->get(); - } + private DriverHarness $harness; + private FixerIo $driver; + + protected function setUp(): void + { + $this->harness = new DriverHarness(); + $driver = $this->harness->make('fixerio'); + $this->assertInstanceOf(FixerIo::class, $driver); + $this->driver = $driver; + } + + #[Test] + public function can_get_latest_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"timestamp":1519296206,"base":"USD","date":"2018-02-22","rates":{"GBP":0.72007,"JPY":107.346001,"EUR":0.813399}}')); + + $result = $this->driver->from(Currency::USD)->get([Currency::GBP, Currency::JPY, Currency::EUR]); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2018-02-22', $result->getDate()); + $this->assertSame('0.72007', (string) $result->rate(Currency::GBP)); + $this->assertSame('107.346001', (string) $result->rate(Currency::JPY)); + $this->assertSame('0.813399', (string) $result->rate(Currency::EUR)); + } + + #[Test] + public function can_get_historical_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"historical":true,"date":"2013-12-24","timestamp":1387929599,"base":"GBP","rates":{"USD":1.636492,"EUR":1.196476,"CAD":1.739516}}')); + + + $result = $this->driver->from(Currency::GBP)->historical( + new DateTimeImmutable('2013-12-24'), + [Currency::USD, Currency::EUR, Currency::CAD], + ); + + $this->assertSame('GBP', $result->getBaseCurrency()); + $this->assertSame('2013-12-24', $result->getDate()); + $this->assertSame('1.636492', (string) $result->rate(Currency::USD)); + $this->assertSame('1.196476', (string) $result->rate(Currency::EUR)); + $this->assertSame('1.739516', (string) $result->rate(Currency::CAD)); + } + + #[Test] + public function fails_to_get_historical_rates_if_date_not_set(): void + { + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->historical(); + } + + #[Test] + public function can_convert_currency_amounts(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"query":{"from":"GBP","to":"JPY","amount":25},"info":{"timestamp":1519328414,"rate":148.972231},"historical":true,"date":"2018-02-22","result":3724.305775}')); + + $result = $this->driver->convert(25.0, Currency::GBP, Currency::JPY, new DateTimeImmutable('2018-02-22')); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('GBP', $result->getBaseCurrency()); + $this->assertSame('2018-02-22', $result->getDate()); + $this->assertEqualsWithDelta(148.972231, $result->rateAsFloat(Currency::JPY), 0.000001); + } + + #[Test] + public function convert_requires_target_currency(): void + { + $this->expectException(ApiException::class); + $this->driver->amount(10.0)->from(Currency::USD)->convert(); + } + + #[Test] + public function convert_requires_amount(): void + { + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->convert(); + } + + #[Test] + public function can_handle_response_failures(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":false,"error":{"code":104,"type":"api_volume_reached","info":"Your monthly API request volume has been reached. Please upgrade your plan."}}')); + + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->get(); + } + + #[Test] + public function access_key_is_added_to_request_query_string(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"success":true,"base":"USD","date":"2024-01-01","rates":{"EUR":0.9}}')); + + $this->driver->accessKey('my-fixerio-key')->from(Currency::USD)->get([Currency::EUR]); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('access_key=my-fixerio-key', $uri); + $this->assertStringContainsString('base=USD', $uri); + $this->assertStringContainsString('symbols=EUR', $uri); + } } diff --git a/tests/Drivers/FrankfurterTest.php b/tests/Drivers/FrankfurterTest.php new file mode 100644 index 0000000..7b3d7b8 --- /dev/null +++ b/tests/Drivers/FrankfurterTest.php @@ -0,0 +1,127 @@ +harness = new DriverHarness(); + $driver = $this->harness->make('frankfurter'); + $this->assertInstanceOf(Frankfurter::class, $driver); + $this->driver = $driver; + } + + #[Test] + public function access_key_is_rejected(): void + { + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Frankfurter does not require an API key.'); + $this->driver->accessKey('any'); + } + + #[Test] + public function can_get_latest_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2024-01-02","rates":{"USD":1.1,"GBP":0.86}}')); + + $result = $this->driver->get([Currency::USD, Currency::GBP]); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('EUR', $result->getBaseCurrency()); + $this->assertSame('2024-01-02', $result->getDate()); + $this->assertSame('1.1', (string) $result->rate(Currency::USD)); + $this->assertSame('0.86', (string) $result->rate(Currency::GBP)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.frankfurter.dev/v1/latest', $uri); + $this->assertStringContainsString('base=EUR', $uri); + $this->assertStringContainsString('symbols=USD%2CGBP', $uri); + } + + #[Test] + public function can_get_latest_rates_without_symbols(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2024-01-02","rates":{"USD":1.1}}')); + + $this->driver->get(); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('base=EUR', $uri); + $this->assertStringNotContainsString('symbols=', $uri); + } + + #[Test] + public function can_get_historical_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2020-04-01","rates":{"USD":1.0934}}')); + + $result = $this->driver->historical(new DateTimeImmutable('2020-04-01'), [Currency::USD]); + + $this->assertSame('2020-04-01', $result->getDate()); + $this->assertSame('1.0934', (string) $result->rate(Currency::USD)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('https://api.frankfurter.dev/v1/2020-04-01', $uri); + } + + #[Test] + public function fails_to_get_historical_rates_if_date_not_set(): void + { + $this->expectException(ApiException::class); + $this->driver->to(Currency::USD)->historical(); + } + + #[Test] + public function convert_uses_latest_rates_when_no_date_given(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2024-01-02","rates":{"USD":1.1}}')); + + $result = $this->driver->convert(10.0, Currency::EUR, Currency::USD); + + $this->assertSame('1.1', (string) $result->rate(Currency::USD)); + } + + #[Test] + public function convert_uses_historical_rates_when_date_given(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"amount":1.0,"base":"EUR","date":"2020-04-01","rates":{"USD":1.0934}}')); + + $result = $this->driver->convert(10.0, Currency::EUR, Currency::USD, new DateTimeImmutable('2020-04-01')); + + $this->assertSame('2020-04-01', $result->getDate()); + $this->assertSame('1.0934', (string) $result->rate(Currency::USD)); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('/2020-04-01', $uri); + } + + #[Test] + public function convert_requires_target_currency(): void + { + $this->expectException(ApiException::class); + $this->driver->amount(10.0)->convert(); + } + + #[Test] + public function convert_requires_amount(): void + { + $this->expectException(ApiException::class); + $this->driver->to(Currency::USD)->convert(); + } +} diff --git a/tests/Drivers/MockCurrencyDriverTest.php b/tests/Drivers/MockCurrencyDriverTest.php index 954ff08..eb622b3 100644 --- a/tests/Drivers/MockCurrencyDriverTest.php +++ b/tests/Drivers/MockCurrencyDriverTest.php @@ -1,40 +1,62 @@ make('mock'); + $this->assertInstanceOf(MockCurrencyDriver::class, $driver); + $this->driver = $driver; + } + + #[Test] + public function returns_conversion_result_for_get(): void + { + $this->assertInstanceOf(ConversionResult::class, $this->driver->get()); + } + + #[Test] + public function returns_conversion_result_for_historical(): void + { + $result = $this->driver->historical(new DateTimeImmutable('2015-01-01')); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('2015-01-01', $result->getDate()); + } + + #[Test] + public function convert_returns_conversion_result_with_target_rate(): void + { + $result = $this->driver->convert(1.0, Currency::USD, Currency::EUR); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertTrue(BigDecimal::of('12.34')->isEqualTo($result->rate(Currency::EUR))); + } + + #[Test] + public function with_rates_seeds_get_response(): void + { + $this->driver->withRates(['EUR' => '0.92', 'GBP' => '0.79']); + + $result = $this->driver->get(); - /** @var BaseCurrencyDriver */ - private $mockCurrencyDriver; - - protected function setUp(): void - { - $this->mockCurrencyDriver = DriverFactory::make('mock'); - } - - /** @test */ - public function can_get_latest_rates() - { - $this->assertInstanceOf(ConversionResult::class, $this->mockCurrencyDriver->get()); - } - - /** @test */ - public function can_get_historical_rates() - { - $this->assertInstanceOf(ConversionResult::class, $this->mockCurrencyDriver->historical('2015-01-01')); - } - - /** @test */ - public function can_convert_currencies() - { - $this->assertEquals(12.34, $this->mockCurrencyDriver->convert(1, Symbol::USD, Symbol::EUR)); - } + $this->assertSame('0.92', (string) $result->rate(Currency::EUR)); + $this->assertSame('0.79', (string) $result->rate(Currency::GBP)); + } } diff --git a/tests/Drivers/OpenExchangeRatesTest.php b/tests/Drivers/OpenExchangeRatesTest.php index dcb1c3b..3bc8724 100644 --- a/tests/Drivers/OpenExchangeRatesTest.php +++ b/tests/Drivers/OpenExchangeRatesTest.php @@ -1,99 +1,102 @@ mockHandler = new MockHandler(); - $this->openExchangeRates = DriverFactory::make('openexchangerates', new Client(['handler' => $this->mockHandler])); - } - - /** @test */ - public function can_set_app_id() - { - $this->assertInstanceOf(OpenExchangeRates::class, $this->openExchangeRates->accessKey('7b23e3e4706c074e2665caf25e823e88')); - } - - /** @test */ - public function can_get_latest_rates() - { - // Response from https://docs.openexchangerates.org - $this->mockHandler->append(new Response(200, [], '{"disclaimer":"https://openexchangerates.org/terms/","license":"https://openexchangerates.org/license/","timestamp":1449877801,"base":"USD","rates":{"AED":3.672538,"AFN":66.809999,"ALL":125.716501,"AMD":484.902502,"ANG":1.788575}}')); - - $result = $this->openExchangeRates->from(Symbol::USD)->get([Symbol::AED, Symbol::AFN, Symbol::ALL, Symbol::AMD, Symbol::ANG]); - - $this->assertInstanceOf(ConversionResult::class, $result); - - $this->assertEquals(Symbol::USD, $result->getBaseCurrency()); - $this->assertEquals('2015-12-11', $result->getDate()); - $this->assertEquals(3.672538, $result->rate(Symbol::AED)); - $this->assertEquals(66.809999, $result->rate(Symbol::AFN)); - $this->assertEquals(125.716501, $result->rate(Symbol::ALL)); - $this->assertEquals(484.902502, $result->rate(Symbol::AMD)); - $this->assertEquals(1.788575, $result->rate(Symbol::ANG)); - } - - - /** @test */ - public function can_get_historical_rates() - { - // Response from https://docs.openexchangerates.org - $this->mockHandler->append(new Response(200, [], '{"disclaimer":"https://openexchangerates.org/terms/","license":"https://openexchangerates.org/license/","timestamp":982342800,"base":"USD","rates":{"AED":3.67246,"ALL":144.529793,"ANG":1.79}}')); - - $result = $this->openExchangeRates->from(Symbol::USD)->historical('2001-02-16', [Symbol::AED, Symbol::AED, Symbol::ANG]); - - $this->assertInstanceOf(ConversionResult::class, $result); - - $this->assertEquals(Symbol::USD, $result->getBaseCurrency()); - $this->assertEquals('2001-02-16', $result->getDate()); - - $this->assertEquals(3.67246, $result->rate(Symbol::AED)); - $this->assertEquals(144.529793, $result->rate(Symbol::ALL)); - $this->assertEquals(1.79, $result->rate(Symbol::ANG)); - } - - /** @test */ - public function fails_to_get_historical_rates_if_date_not_set() - { - $this->expectException(ApiException::class); - $this->openExchangeRates->from(Symbol::USD)->to(Symbol::EUR)->historical(); - } - - /** @test */ - public function can_convert_currency_amounts() - { - // Response from https://docs.openexchangerates.org - $this->mockHandler->append(new Response(200, [], '{"disclaimer":"https://openexchangerates.org/terms/","license":"https://openexchangerates.org/license/","request":{"query":"/convert/19999.95/GBP/EUR","amount":19999.95,"from":"GBP","to":"EUR"},"meta":{"timestamp":1449885661,"rate":1.383702},"response":27673.975864}')); - - $result = $this->openExchangeRates->convert(19999.95, Symbol::GBP, Symbol::EUR, '2015-12-12'); - $this->assertEquals(27673.975864, $result); - } - - /** @test */ - public function can_handle_response_failures() - { - // Response from https://docs.openexchangerates.org - $this->mockHandler->append(new Response(200, [], '{"error":true,"status":401,"message":"invalid_app_id","description":"Invalid App ID provided - please sign up at https://openexchangerates.org/signup, or contact support@openexchangerates.org."}')); - - $this->expectException(ApiException::class); - $this->openExchangeRates->from(Symbol::USD)->to(Symbol::LTL)->get(); - } + private DriverHarness $harness; + private OpenExchangeRates $driver; + + protected function setUp(): void + { + $this->harness = new DriverHarness(); + $driver = $this->harness->make('openexchangerates'); + $this->assertInstanceOf(OpenExchangeRates::class, $driver); + $this->driver = $driver; + } + + #[Test] + public function access_key_is_sent_as_app_id_query_param(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"base":"USD","timestamp":1700000000,"rates":{"EUR":0.9}}')); + + $this->driver->accessKey('app-id-token')->from(Currency::USD)->get([Currency::EUR]); + + $uri = (string) $this->harness->http->lastRequest()?->getUri(); + $this->assertStringContainsString('app_id=app-id-token', $uri); + $this->assertStringNotContainsString('access_key=', $uri); + } + + #[Test] + public function can_get_latest_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"timestamp":1449877801,"base":"USD","rates":{"AED":3.672538,"AFN":66.809999,"ALL":125.716501,"AMD":484.902502,"ANG":1.788575}}')); + + $result = $this->driver->from(Currency::USD)->get([Currency::AED, Currency::AFN, Currency::ALL, Currency::AMD, Currency::ANG]); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2015-12-11', $result->getDate()); + $this->assertSame('3.672538', (string) $result->rate(Currency::AED)); + $this->assertSame('66.809999', (string) $result->rate(Currency::AFN)); + $this->assertSame('125.716501', (string) $result->rate(Currency::ALL)); + $this->assertSame('484.902502', (string) $result->rate(Currency::AMD)); + $this->assertSame('1.788575', (string) $result->rate(Currency::ANG)); + } + + #[Test] + public function can_get_historical_rates(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"timestamp":982342800,"base":"USD","rates":{"AED":3.67246,"ALL":144.529793,"ANG":1.79}}')); + + $result = $this->driver->from(Currency::USD)->historical( + new DateTimeImmutable('2001-02-16'), + [Currency::AED, Currency::ALL, Currency::ANG], + ); + + $this->assertSame('USD', $result->getBaseCurrency()); + $this->assertSame('2001-02-16', $result->getDate()); + $this->assertSame('3.67246', (string) $result->rate(Currency::AED)); + $this->assertSame('144.529793', (string) $result->rate(Currency::ALL)); + $this->assertSame('1.79', (string) $result->rate(Currency::ANG)); + } + + #[Test] + public function fails_to_get_historical_rates_if_date_not_set(): void + { + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->historical(); + } + + #[Test] + public function can_convert_currency_amounts(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"request":{"query":"/convert/19999.95/GBP/EUR","amount":19999.95,"from":"GBP","to":"EUR"},"meta":{"timestamp":1449885661,"rate":1.383702},"response":27673.975864,"result":27673.975864}')); + + $result = $this->driver->convert(19999.95, Currency::GBP, Currency::EUR); + + $this->assertInstanceOf(ConversionResult::class, $result); + $this->assertEqualsWithDelta(1.383702, $result->rateAsFloat(Currency::EUR), 0.000001); + } + + #[Test] + public function can_handle_response_failures(): void + { + $this->harness->http->enqueue(new Response(200, [], '{"error":true,"status":401,"message":"invalid_app_id","description":"Invalid App ID provided"}')); + + $this->expectException(ApiException::class); + $this->driver->from(Currency::USD)->to(Currency::EUR)->get(); + } } diff --git a/tests/Drivers/TestableDriver.php b/tests/Drivers/TestableDriver.php new file mode 100644 index 0000000..84b6f6f --- /dev/null +++ b/tests/Drivers/TestableDriver.php @@ -0,0 +1,51 @@ +getBaseCurrency()); + } + + #[Override] + public function historical( + ?DateTimeInterface $date = null, + string|Currency|array $forCurrency = [], + ): ConversionResult { + return new ConversionResult($this->getBaseCurrency()); + } + + #[Override] + public function convert( + ?float $amount = null, + string|Currency|null $fromCurrency = null, + string|Currency|null $toCurrency = null, + ?DateTimeInterface $date = null, + ): ConversionResult { + return new ConversionResult($this->getBaseCurrency()); + } + + /** + * @param array $params + * + * @return array + */ + public function callApi(string $endpoint, array $params = []): array + { + return $this->apiRequest($endpoint, $params); + } +} diff --git a/tests/Helpers/DateHelperTest.php b/tests/Helpers/DateHelperTest.php index 949d65d..b62ef02 100644 --- a/tests/Helpers/DateHelperTest.php +++ b/tests/Helpers/DateHelperTest.php @@ -1,44 +1,48 @@ assertNull(DateHelper::format(null)); + } + + #[Test] + public function format_uses_iso_date_by_default(): void + { + $date = new DateTimeImmutable('2019-01-01 12:34:56'); + $this->assertSame('2019-01-01', DateHelper::format($date)); + } + + #[Test] + public function format_accepts_custom_format_string(): void + { + $date = new DateTimeImmutable('2019-01-01 12:34:56'); + $this->assertSame('01.01.2019 12:34', DateHelper::format($date, 'd.m.Y H:i')); + } + + #[Test] + public function now_returns_current_timestamp(): void + { + $delta = DateHelper::now()->getTimestamp() - time(); + $this->assertLessThanOrEqual(1, abs($delta)); + } - /** @test */ - public function can_parse_a_date() - { - $this->assertEquals('23:15:03', DateHelper::parse('23h 15m 03s', 'H\h i\m s\s')->format('H:i:s')); - } - - /** @test */ - public function can_format_a_date() - { - $this->assertEqualsWithDelta((new DateTime())->format('Y-m-d'), DateHelper::format('now', 'Y-m-d'), 0.1); - $this->assertEquals('2019-01-01', DateHelper::format(1546300800, 'Y-m-d')); - $this->assertEquals('2019-01-01', DateHelper::format('2019-01-01', 'Y-m-d')); - $this->assertEquals(DateHelper::today()->format('Y-m-d'), DateHelper::format(DateHelper::today(), 'Y-m-d')); - } - - /** @test */ - public function can_create_a_date() - { - $this->assertEquals(1546300800, DateHelper::create('1.1.2019')->getTimestamp()); - } - - /** @test */ - public function can_get_current_date_and_time() - { - $this->assertEqualsWithDelta(new DateTime(), DateHelper::now(), 0.1); - } - - /** @test */ - public function can_get_current_date() - { - $this->assertEquals(new DateTime('today'), DateHelper::today()); - } + #[Test] + public function today_returns_midnight_today(): void + { + $today = DateHelper::today(); + $this->assertSame('00:00:00', $today->format('H:i:s')); + $this->assertSame((new DateTimeImmutable('today'))->format('Y-m-d'), $today->format('Y-m-d')); + } } diff --git a/tests/Results/ConversionResultTest.php b/tests/Results/ConversionResultTest.php index bb81994..bf3b2c7 100644 --- a/tests/Results/ConversionResultTest.php +++ b/tests/Results/ConversionResultTest.php @@ -1,118 +1,172 @@ classUnderTest = new ConversionResult(Symbol::USD, 1560293762, [ - 'EUR' => 0.88, - 'THB' => 31.27, - ]); - } - - /** @test */ - public function construct_will_properly_set_parameters() - { - $this->assertEquals(Symbol::USD, $this->classUnderTest->getBaseCurrency()); - $this->assertEquals('2019-06-11', $this->classUnderTest->getDate()); - - $result = new ConversionResult(Symbol::USD, '1936-07-21', [ - 'CNY' => 1.12, - ]); - - $this->assertEquals('1936-07-21', $result->getDate()); - - $result = new ConversionResult(Symbol::EUR, DateTime::createFromFormat('d.m.Y', '5.10.1990'), [ - 'LTL' => 3.45280, - ]); - - $this->assertEquals('1990-10-05', $result->getDate()); - $this->assertEquals(Symbol::EUR, $result->getBaseCurrency()); - } - - /** @test */ - public function returns_all_conversion_rates() - { - $this->assertCount(3, $this->classUnderTest->all()); - $this->assertArrayHasKey(Symbol::USD, $this->classUnderTest->all()); - $this->assertArrayHasKey(Symbol::EUR, $this->classUnderTest->all()); - $this->assertArrayHasKey(Symbol::THB, $this->classUnderTest->all()); - $this->assertEquals(1, $this->classUnderTest->all()[Symbol::USD]); - $this->assertEquals(31.27, $this->classUnderTest->all()[Symbol::THB]); - } - - /** @test */ - public function fails_to_convert_if_target_currency_does_not_exist() - { - $this->expectException(CurrencyException::class); - $this->classUnderTest->convert(2, Symbol::EUR, Symbol::BTC); - } - - /** @test */ - public function fails_to_convert_if_source_currency_does_not_exist() - { - $this->expectException(CurrencyException::class); - $this->classUnderTest->convert(2, Symbol::BTC, Symbol::EUR); - } - - /** @test */ - public function can_convert_between_currencies() - { - $result = $this->classUnderTest->convert(2, Symbol::EUR, Symbol::THB); - $this->assertEqualsWithDelta(71.06, $result, 0.1); - } - - /** @test */ - public function fails_to_retrieve_rate_if_currency_does_not_exist() - { - $this->expectException(CurrencyException::class); - $this->classUnderTest->rate(Symbol::BTC); - } - - /** @test */ - public function retrieves_currency_conversion_rate() - { - $this->assertEquals(31.27, $this->classUnderTest->rate(Symbol::THB)); - $this->assertEquals(0.88, $this->classUnderTest->rate(Symbol::EUR)); - } - - /** @test */ - public function fails_to_change_base_currency_if_currency_does_not_exist() - { - $this->expectException(CurrencyException::class); - $this->classUnderTest->setBaseCurrency(Symbol::BTC); - } - - /** @test */ - public function can_reset_base_currency() - { - $this->classUnderTest->setBaseCurrency(Symbol::USD); - $this->assertEquals(0.88, $this->classUnderTest->rate(Symbol::EUR)); - $this->assertEquals(1, $this->classUnderTest->rate(Symbol::USD)); - } - - /** @test */ - public function can_change_base_currency() - { - $this->classUnderTest->setBaseCurrency(Symbol::EUR); - $this->assertEqualsWithDelta(1.14, $this->classUnderTest->rate(Symbol::USD), 0.1); - $this->assertEquals(1, $this->classUnderTest->rate(Symbol::EUR)); - - $this->assertEquals(1, $this->classUnderTest->convert(0.88, Symbol::EUR, Symbol::USD)); - $this->assertEquals(1, $this->classUnderTest->convert(31.27, Symbol::THB, Symbol::USD)); - } + private ConversionResult $result; + + protected function setUp(): void + { + $this->result = new ConversionResult(Currency::USD, '2019-06-11', [ + 'EUR' => 0.88, + 'THB' => 31.27, + ]); + } + + #[Test] + public function construct_will_properly_set_parameters(): void + { + $this->assertSame('USD', $this->result->getBaseCurrency()); + $this->assertSame('2019-06-11', $this->result->getDate()); + + $other = new ConversionResult(Currency::USD, '1936-07-21', ['CNY' => 1.12]); + $this->assertSame('1936-07-21', $other->getDate()); + + $third = new ConversionResult(Currency::EUR, '1990-10-05', ['LTL' => 3.45280]); + $this->assertSame('1990-10-05', $third->getDate()); + $this->assertSame('EUR', $third->getBaseCurrency()); + } + + #[Test] + public function returns_all_conversion_rates_including_base(): void + { + $rates = $this->result->all(); + + $this->assertCount(3, $rates); + $this->assertArrayHasKey('USD', $rates); + $this->assertArrayHasKey('EUR', $rates); + $this->assertArrayHasKey('THB', $rates); + + $this->assertTrue(BigDecimal::one()->isEqualTo($rates['USD'])); + $this->assertSame('31.27', (string) $rates['THB']); + } + + #[Test] + public function all_as_floats_returns_native_floats(): void + { + $rates = $this->result->allAsFloats(); + + $this->assertSame(1.0, $rates['USD']); + $this->assertSame(0.88, $rates['EUR']); + $this->assertSame(31.27, $rates['THB']); + } + + #[Test] + public function fails_to_convert_if_target_currency_does_not_exist(): void + { + $this->expectException(CurrencyException::class); + $this->result->convert(2, Currency::EUR, Currency::BTC); + } + + #[Test] + public function fails_to_convert_if_source_currency_does_not_exist(): void + { + $this->expectException(CurrencyException::class); + $this->result->convert(2, Currency::BTC, Currency::EUR); + } + + #[Test] + public function can_convert_between_currencies(): void + { + $converted = $this->result->convert(2, Currency::EUR, Currency::THB); + + $this->assertEqualsWithDelta(71.06, $converted->toFloat(), 0.01); + } + + #[Test] + public function fails_to_retrieve_rate_if_currency_does_not_exist(): void + { + $this->expectException(CurrencyException::class); + $this->result->rate(Currency::BTC); + } + + #[Test] + public function retrieves_currency_conversion_rate(): void + { + $this->assertSame('31.27', (string) $this->result->rate(Currency::THB)); + $this->assertSame('0.88', (string) $this->result->rate(Currency::EUR)); + $this->assertSame(31.27, $this->result->rateAsFloat(Currency::THB)); + } + + #[Test] + public function fails_to_change_base_currency_if_currency_does_not_exist(): void + { + $this->expectException(CurrencyException::class); + $this->result->setBaseCurrency(Currency::BTC); + } + + #[Test] + public function reset_to_original_base_currency_restores_original_rates(): void + { + $this->result->setBaseCurrency(Currency::EUR); + $this->result->setBaseCurrency(Currency::USD); + + $this->assertSame('0.88', (string) $this->result->rate(Currency::EUR)); + $this->assertTrue(BigDecimal::one()->isEqualTo($this->result->rate(Currency::USD))); + } + + #[Test] + public function can_change_base_currency_and_convert_back_losslessly(): void + { + $this->result->setBaseCurrency(Currency::EUR); + + $this->assertEqualsWithDelta(1.1363, $this->result->rateAsFloat(Currency::USD), 0.001); + $this->assertTrue(BigDecimal::one()->isEqualTo($this->result->rate(Currency::EUR))); + + $this->assertSame('EUR', $this->result->getBaseCurrency()); + $this->assertSame('USD', $this->result->originalBaseCurrency); + } + + #[Test] + public function convert_round_trips_via_original_base(): void + { + $this->assertEqualsWithDelta( + 1.0, + $this->result->convert(0.88, Currency::EUR, Currency::USD)->toFloat(), + 0.0001, + ); + + $this->assertEqualsWithDelta( + 1.0, + $this->result->convert(31.27, Currency::THB, Currency::USD)->toFloat(), + 0.0001, + ); + } + + #[Test] + public function setting_base_to_self_returns_self(): void + { + $this->result->setBaseCurrency(Currency::USD); + $this->assertSame('USD', $this->result->getBaseCurrency()); + $this->assertTrue(BigDecimal::one()->isEqualTo($this->result->rate(Currency::USD))); + } + + #[Test] + public function constructor_accepts_string_base_currency(): void + { + $result = new ConversionResult('JPY', '2024-01-01', ['EUR' => '0.0061']); + + $this->assertSame('JPY', $result->getBaseCurrency()); + $this->assertSame('JPY', $result->originalBaseCurrency); + } + + #[Test] + public function rates_can_be_passed_as_big_decimal_already(): void + { + $result = new ConversionResult(Currency::USD, '2024-01-01', [ + 'EUR' => BigDecimal::of('0.92'), + ]); + + $this->assertSame('0.92', (string) $result->rate(Currency::EUR)); + } } diff --git a/tests/Support/DriverHarness.php b/tests/Support/DriverHarness.php new file mode 100644 index 0000000..851eac8 --- /dev/null +++ b/tests/Support/DriverHarness.php @@ -0,0 +1,24 @@ +http = new MockHttpClient(); + } + + public function make(string $name): CurrencyDriverContract + { + return (new DriverFactory())->build($name, $this->http, new RequestFactory()); + } +} diff --git a/tests/Support/JsonResponse.php b/tests/Support/JsonResponse.php new file mode 100644 index 0000000..bd655b3 --- /dev/null +++ b/tests/Support/JsonResponse.php @@ -0,0 +1,24 @@ + 'application/json'], $body); + } + + /** + * @param array $headers + */ + public static function with(int $status, string $body, array $headers = []): ResponseInterface + { + return new Response($status, $headers + ['Content-Type' => 'application/json'], $body); + } +} diff --git a/tests/Support/MockHttpClient.php b/tests/Support/MockHttpClient.php new file mode 100644 index 0000000..b62452e --- /dev/null +++ b/tests/Support/MockHttpClient.php @@ -0,0 +1,57 @@ + */ + private array $queue = []; + + /** @var list */ + private array $sentRequests = []; + + public function enqueue(ResponseInterface|ClientExceptionInterface $item): self + { + $this->queue[] = $item; + + return $this; + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->sentRequests[] = $request; + + if ($this->queue === []) { + throw new MockHttpClientException('MockHttpClient queue is empty.'); + } + + $next = array_shift($this->queue); + + if ($next instanceof ClientExceptionInterface) { + throw $next; + } + + return $next; + } + + public function lastRequest(): ?RequestInterface + { + return $this->sentRequests[count($this->sentRequests) - 1] ?? null; + } + + /** + * @return list + */ + public function sentRequests(): array + { + return $this->sentRequests; + } +} diff --git a/tests/SymbolTest.php b/tests/SymbolTest.php index f1fd445..0be7ddf 100644 --- a/tests/SymbolTest.php +++ b/tests/SymbolTest.php @@ -1,31 +1,68 @@ assertCount(167, Symbol::all()); - } - - /** @test */ - public function can_get_a_symbol_name() - { - $this->assertEquals('Lithuanian Litas', Symbol::name(Symbol::LTL)); - $this->assertEquals('Bitcoin', Symbol::name(Symbol::BTC)); - } - - /** @test */ - public function can_get_a_list_of_all_symbols() - { - $this->assertCount(167, Symbol::names()); - $this->assertEquals('Lithuanian Litas', Symbol::names()[Symbol::LTL]); - $this->assertEquals('Bitcoin', Symbol::names()[Symbol::BTC]); - } + #[Test] + public function can_get_all_symbols(): void + { + $this->assertCount(167, @Symbol::all()); + } + + #[Test] + public function can_get_a_symbol_name(): void + { + $this->assertSame('Lithuanian Litas', @Symbol::name(Symbol::LTL)); + $this->assertSame('Bitcoin', @Symbol::name(Symbol::BTC)); + } + + #[Test] + public function can_get_a_list_of_all_symbols(): void + { + $names = @Symbol::names(); + + $this->assertCount(167, $names); + $this->assertSame('Lithuanian Litas', $names[Symbol::LTL]); + $this->assertSame('Bitcoin', $names[Symbol::BTC]); + } + + #[Test] + public function symbol_constants_resolve_to_currency_codes(): void + { + $this->assertSame('USD', Symbol::USD); + $this->assertSame('EUR', Symbol::EUR); + } + + #[Test] + public function deprecated_methods_trigger_user_deprecated_notice(): void + { + Symbol::resetDeprecationNotice(); + $messages = []; + set_error_handler(static function (int $errno, string $errstr) use (&$messages): bool { + if ($errno === E_USER_DEPRECATED) { + $messages[] = $errstr; + + return true; + } + + return false; + }); + + try { + Symbol::all(); + } finally { + restore_error_handler(); + } + + $this->assertNotEmpty($messages); + $this->assertStringContainsString(Currency::class, $messages[0]); + } }