diff --git a/.gitattributes b/.gitattributes index 6376ac0..9bb0cf7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,7 +18,7 @@ .github/ export-ignore .gitignore export-ignore .markdownlint.json export-ignore -.php-cs-fixer.php export-ignore +.php-cs-fixer.* export-ignore .phpstan/ export-ignore .phpunit.* export-ignore codecov.yml export-ignore @@ -26,7 +26,7 @@ coverage/ export-ignore coverage.clover export-ignore coverage.xml export-ignore DEVELOPMENT.md export-ignore -phpstan/ export-ignore +phpstan.neon.dist export-ignore phpunit.xml* export-ignore tests/ export-ignore var/ export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5f83f7..051d89d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,7 +7,7 @@ on: - 'legacy/*' # Legacy branches: legacy/v3.x - 'feature/*' # Feature branches: feature/new-feature - 'hotfix/*' # Hotfix branches: hotfix/urgent-fix - - 'release/*' # Release branches: release/v4.0.0 + - 'release/*' # Release branches: release/v4.1.0 pull_request: branches: - 'main' @@ -16,13 +16,16 @@ on: jobs: test: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: include: # Symfony 6.4 LTS compatibility + - php: '8.1' + symfony-version: '6.4.*' + allowed-to-fail: false - php: '8.2' symfony-version: '6.4.*' allowed-to-fail: false @@ -63,17 +66,47 @@ jobs: - php: '8.4' symfony-version: '8.0.*' allowed-to-fail: false - - # PHP 8.5 with various Symfony versions - php: '8.5' - symfony-version: '7.3.*' + symfony-version: '8.0.*' + allowed-to-fail: false + + # Symfony 8.1 + - php: '8.4' + symfony-version: '8.1.*' allowed-to-fail: false - php: '8.5' - symfony-version: '7.4.*' + symfony-version: '8.1.*' allowed-to-fail: false + + # Symfony 8.2 (development / pre-release) + - php: '8.4' + symfony-version: '8.2.*' + allowed-to-fail: true - php: '8.5' + symfony-version: '8.2.*' + allowed-to-fail: true + + # PHP 8.6 (beta / experimental) + - php: '8.6' symfony-version: '8.0.*' - allowed-to-fail: false + allowed-to-fail: true + - php: '8.6' + symfony-version: '8.1.*' + allowed-to-fail: true + - php: '8.6' + symfony-version: '8.2.*' + allowed-to-fail: true + + # Development stability tests + - php: '8.4' + stability: 'dev' + allowed-to-fail: true + - php: '8.5' + stability: 'dev' + allowed-to-fail: true + - php: '8.6' + stability: 'dev' + allowed-to-fail: true name: "PHP ${{ matrix.php }}${{ matrix.symfony-version && format(' | Symfony {0}', matrix.symfony-version) || '' }}${{ matrix.stability && format(' | {0}', matrix.stability) || '' }}" @@ -81,7 +114,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -96,7 +129,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -136,35 +169,22 @@ jobs: run: composer cs - name: Run static analysis - run: | - if [[ "${{ matrix.symfony-version }}" == "8.0.*" ]]; then - # Symfony 8.0: Use specific baseline for remaining issues - vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony8-baseline.neon - elif [[ "${{ matrix.symfony-version }}" == "7.4.*" ]]; then - # Symfony 7.4 LTS: Functionally identical to 8.0, needs same baseline - vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony74-baseline.neon - elif [[ "${{ matrix.symfony-version }}" == "6.4.*" || "${{ matrix.symfony-version }}" == "7.0.*" || "${{ matrix.symfony-version }}" == "7.1.*" || "${{ matrix.symfony-version }}" == "7.2.*" || "${{ matrix.symfony-version }}" == "7.3.*" ]]; then - # Older Symfony versions 6.4-7.3: Use legacy baseline for children() issue - composer analyse-legacy - else - # Future versions: Try without baseline first - composer analyse - fi + run: composer analyse -- --memory-limit=1G - name: Run tests run: composer test env: - DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} - DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} - DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} + DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} + DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} + DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} code-quality: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 name: Code Quality Checks steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -179,7 +199,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -197,12 +217,12 @@ jobs: fi coverage: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 name: Code Coverage steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -217,7 +237,7 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v4 + uses: actions/cache@v6 with: path: ${{ steps.composer-cache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -229,14 +249,14 @@ jobs: - name: Run tests with coverage run: composer test-coverage env: - DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} - DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} - DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} + DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} + DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} + DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v7 with: - file: ./coverage.xml + files: ./coverage.xml flags: unittests name: codecov-umbrella fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index d9352da..47c91e2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ composer.lock # PHP CS Fixer .php-cs-fixer.cache +.php-cs-fixer.php # Coverage reports coverage/ diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000..beb43ec --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,22 @@ +in(__DIR__ . '/src') + ->in(__DIR__ . '/tests') + ->exclude('vendor') + ->exclude('coverage') + ->exclude('.phpunit.cache') + ->name('*.php'); + +$config = new PhpCsFixer\Config(); +$config->setFinder($finder) + ->setRules([ + '@PSR12' => true, + '@PSR12:risky' => true, + ]) + ->setRiskyAllowed(true) + ->setUnsupportedPhpVersionAllowed(true); + +return $config; diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php deleted file mode 100644 index 04eec8c..0000000 --- a/.php-cs-fixer.php +++ /dev/null @@ -1,19 +0,0 @@ -in(__DIR__.'/src') - ->in(__DIR__.'/tests') - ->exclude('vendor'); - -$config = new PhpCsFixer\Config(); -$config->setFinder($finder) - ->setRules([ - '@Symfony' => true, - '@Symfony:risky' => true, - ]) - ->setRiskyAllowed(true); - -// @phpstan-ignore-next-line Method exists but not detected by static analysis -$config->setUnsupportedPhpVersionAllowed(true); - -return $config; diff --git a/CHANGELOG.md b/CHANGELOG.md index 344e5cc..a4abd19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ 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.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [4.1.0](https://github.com/calliostro/discogs-bundle/releases/tag/v4.1.0) โ€“ 2026-09-19 + +### Added + +- Upgraded dependency to `calliostro/php-discogs-api` v4.1 with built-in retry resilience on Discogs rate limits (`429`) and server overload (`503`). +- Added `auto_retry` (default `true`) and `max_retries` (default `3`) configuration options in `calliostro_discogs`. +- Compatibility testing and CI matrix coverage for PHP 8.1โ€“8.6 and Symfony 6.4 LTS, 7.x, 8.0, 8.1, and 8.2. +- Created root `phpstan.neon.dist` (Level 8) and migrated `.php-cs-fixer.php` to `.php-cs-fixer.dist.php`. +- Added unit tests for new retry configuration and extension options maintaining 100% code coverage. + +### Changed + +- Updated GitHub Actions runners to `ubuntu-24.04` and modernized action versions to Node 24 compatible runners. +- Overhauled README and documentation tone, adopting native GitHub Markdown alerts, backtick method formatting, and sister bundle links. + ## [4.0.0](https://github.com/calliostro/discogs-bundle/releases/tag/v4.0.0) โ€“ 2025-12-01 ### ๐Ÿš€ Complete Rewrite โ€” Fresh Start diff --git a/README.md b/README.md index 980cf66..c3c6d7c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# โšก Discogs Client Bundle for Symfony โ€“ Complete Music Database Access +# Discogs API Bundle for Symfony [![Package Version](https://img.shields.io/packagist/v/calliostro/discogs-bundle.svg)](https://packagist.org/packages/calliostro/discogs-bundle) [![Total Downloads](https://img.shields.io/packagist/dt/calliostro/discogs-bundle.svg)](https://packagist.org/packages/calliostro/discogs-bundle) @@ -7,11 +7,9 @@ [![CI](https://github.com/calliostro/discogs-bundle/actions/workflows/ci.yml/badge.svg)](https://github.com/calliostro/discogs-bundle/actions/workflows/ci.yml) [![Code Coverage](https://codecov.io/gh/calliostro/discogs-bundle/graph/badge.svg?token=3ATEFYF7A0)](https://codecov.io/gh/calliostro/discogs-bundle) [![PHPStan Level](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg)](https://phpstan.org/) -[![Code Style](https://img.shields.io/badge/code%20style-Symfony-brightgreen.svg)](https://github.com/FriendsOfPHP/PHP-CS-Fixer) +[![Code Style](https://img.shields.io/badge/code%20style-PSR12-brightgreen.svg)](https://github.com/FriendsOfPHP/PHP-CS-Fixer) -> **๐Ÿš€ SYMFONY INTEGRATION!** Seamless autowiring for the complete Discogs music database API. Zero bloat, maximum performance. - -Symfony bundle that integrates the **modern** [calliostro/php-discogs-api](https://github.com/calliostro/php-discogs-api) into your Symfony application. Built with modern PHP 8.1+ features, dependency injection, and powered by Guzzle. +A Symfony bundle integrating [`calliostro/php-discogs-api`](https://github.com/calliostro/php-discogs-api) into your Symfony application. Provides dependency injection, autowiring, built-in retry resilience, and optional rate limiting for PHP 8.1+ and Symfony 6.4, 7.x, and 8.x. ## ๐Ÿ“ฆ Installation @@ -21,6 +19,8 @@ Install via Composer: composer require calliostro/discogs-bundle ``` +--- + ## โš™๏ธ Configuration Configure the bundle in `config/packages/calliostro_discogs.yaml`: @@ -29,43 +29,53 @@ Configure the bundle in `config/packages/calliostro_discogs.yaml`: calliostro_discogs: # Recommended: Personal Access Token (get from https://www.discogs.com/settings/developers) personal_access_token: '%env(DISCOGS_PERSONAL_ACCESS_TOKEN)%' - + # Alternative: Consumer credentials for OAuth applications # consumer_key: '%env(DISCOGS_CONSUMER_KEY)%' # consumer_secret: '%env(DISCOGS_CONSUMER_SECRET)%' - + # Optional: HTTP User-Agent header for API requests # user_agent: 'MyApp/1.0 +https://myapp.com' - - # Optional: Professional rate limiting (requires symfony/rate-limiter) - # rate_limiter: discogs_api # Your configured RateLimiterFactory service + + # Optional: Retry resilience settings (enabled by default) + # auto_retry: true # Automatically wait and retry on 429 and 503 responses (default: true) + # max_retries: 3 # Maximum number of retry attempts (default: 3) + + # Optional: Proactive rate limiting (requires symfony/rate-limiter) + # rate_limiter: discogs_api ``` -**Personal Access Token:** You need to [get your token](https://www.discogs.com/settings/developers) from Discogs to access your account data and get higher rate limits. For read-only operations on public data, you can use anonymous access. +> [!NOTE] +> By default, the client uses `DiscogsClient/4.1.0 (+https://github.com/calliostro/php-discogs-api)` as User-Agent. You can override this in the configuration if needed. + +### Authentication Methods -**Consumer Credentials:** For building applications that need OAuth authentication, [register your app](https://www.discogs.com/applications) at Discogs to get your consumer key and secret. +- **Personal Access Token:** Obtain your token from [Discogs Developer Settings](https://www.discogs.com/settings/developers) to access user-specific data (collections, wantlists) and benefit from higher rate limits (60 requests/min). +- **Consumer Credentials:** For OAuth applications, register your application on Discogs to obtain your `consumer_key` and `consumer_secret`. +- **Anonymous Access:** If no credentials are configured, the bundle initializes the client for public data endpoints (subject to unauthenticated rate limits of 25 requests/min). -**User-Agent:** By default, the client uses `DiscogsClient/4.0.0 (+https://github.com/calliostro/php-discogs-api)` as User-Agent. You can override this in the configuration if needed. +--- ## ๐Ÿš€ Quick Start ### Basic Usage +Inject the `DiscogsClient` service directly into your controllers or services: + ```php getArtist(artistId: $id); - $releases = $client->listArtistReleases(artistId: $id, perPage: 5); + $artist = $client->getArtist(artistId: (int) $id); + $releases = $client->listArtistReleases(artistId: (int) $id, perPage: 5); return new JsonResponse([ 'artist' => $artist['name'], @@ -86,13 +96,16 @@ $wantlist = $client->getUserWantlist(username: 'your-username'); $client->addToCollection( username: 'your-username', folderId: 1, - releaseId: 30359313 // Billie Eilish - Happier Than Ever + releaseId: 30359313 ); -$client->addToWantlist(username: 'your-username', releaseId: 28409710); // Taylor Swift - Midnights +$client->addToWantlist( + username: 'your-username', + releaseId: 28409710 +); ``` -### Search and Discovery +### Search and Database Lookups ```php $results = $client->search( @@ -100,104 +113,84 @@ $results = $client->search( type: 'artist' ); -$releases = $client->listArtistReleases(artistId: 4470662); // Billie Eilish -$release = $client->getRelease(releaseId: 30359313); // Happier Than Ever -$master = $client->getMaster(masterId: 2835729); // Midnights master -$label = $client->getLabel(labelId: 12677); // Interscope Records +$releases = $client->listArtistReleases(artistId: 4470662); +$release = $client->getRelease(releaseId: 30359313); +$master = $client->getMaster(masterId: 2835729); +$label = $client->getLabel(labelId: 12677); ``` +--- + ## โœจ Key Features -- **Ultra-Lightweight** โ€“ Minimal Symfony integration with zero bloat for the ultra-lightweight Discogs client -- **Complete API Coverage** โ€“ All 60 Discogs API endpoints supported -- **Direct API Calls** โ€“ `$client->getArtist(id: 123)` maps to `/artists/{id}`, no abstractions -- **Type Safe + IDE Support** โ€“ Full PHP 8.1+ types, PHPStan Level 8, method autocomplete -- **Symfony Native** โ€“ Seamless autowiring with Symfony 6.4, 7.x & 8.x -- **Well Tested** โ€“ Comprehensive test coverage, Symfony coding standards -- **Multiple Auth Methods** โ€“ Personal Access Token, OAuth 1.0a, Consumer Credentials, Anonymous +- **Lightweight Integration** โ€“ Minimal footprint with zero overhead on top of `calliostro/php-discogs-api`. +- **Complete API Coverage** โ€“ All 60 Discogs API endpoints supported. +- **Direct API Calls** โ€“ `$client->getArtist(artistId: 123)` maps directly to `/artists/{id}`. +- **Built-in Retry Resilience** โ€“ Automatic exponential backoff and retry handling for `429 Too Many Requests` and `503 Service Unavailable` responses. +- **Type Safe & IDE Support** โ€“ PHP 8.1+ types, named parameters, and PHPStan Level 8 static analysis. +- **Symfony Native** โ€“ Autowiring support for Symfony 6.4, 7.x, and 8.x. +- **Multiple Authentication Methods** โ€“ Personal Access Token, OAuth 1.0a, Consumer Credentials, and Anonymous access. + +--- ## ๐ŸŽต All Discogs API Methods as Direct Calls -- **Database Methods** โ€“ search(), getArtist(), listArtistReleases(), getRelease(), getUserReleaseRating(), updateUserReleaseRating(), deleteUserReleaseRating(), getCommunityReleaseRating(), getReleaseStats(), getMaster(), listMasterVersions(), getLabel(), listLabelReleases() -- **User Identity Methods** โ€“ getIdentity(), getUser(), updateUser(), listUserSubmissions(), listUserContributions() -- **User Collection Methods** โ€“ listCollectionFolders(), getCollectionFolder(), createCollectionFolder(), updateCollectionFolder(), deleteCollectionFolder(), listCollectionItems(), getCollectionItemsByRelease(), addToCollection(), updateCollectionItem(), removeFromCollection(), getCustomFields(), setCustomFields(), getCollectionValue() -- **User Wantlist Methods** โ€“ getUserWantlist(), addToWantlist(), updateWantlistItem(), removeFromWantlist() -- **User Lists Methods** โ€“ getUserLists(), getUserList() -- **Marketplace Methods** โ€“ getUserInventory(), getMarketplaceListing(), createMarketplaceListing(), updateMarketplaceListing(), deleteMarketplaceListing(), getMarketplaceFee(), getMarketplaceFeeByCurrency(), getMarketplacePriceSuggestions(), getMarketplaceStats(), getMarketplaceOrder(), getMarketplaceOrders(), updateMarketplaceOrder(), getMarketplaceOrderMessages(), addMarketplaceOrderMessage() -- **Inventory Export Methods** โ€“ createInventoryExport(), listInventoryExports(), getInventoryExport(), downloadInventoryExport() -- **Inventory Upload Methods** โ€“ addInventoryUpload(), changeInventoryUpload(), deleteInventoryUpload(), listInventoryUploads(), getInventoryUpload() +- **Database Methods** โ€“ `search()`, `getArtist()`, `listArtistReleases()`, `getRelease()`, `getUserReleaseRating()`, `updateUserReleaseRating()`, `deleteUserReleaseRating()`, `getCommunityReleaseRating()`, `getReleaseStats()`, `getMaster()`, `listMasterVersions()`, `getLabel()`, `listLabelReleases()` +- **User Identity Methods** โ€“ `getIdentity()`, `getUser()`, `updateUser()`, `listUserSubmissions()`, `listUserContributions()` +- **User Collection Methods** โ€“ `listCollectionFolders()`, `getCollectionFolder()`, `createCollectionFolder()`, `updateCollectionFolder()`, `deleteCollectionFolder()`, `listCollectionItems()`, `getCollectionItemsByRelease()`, `addToCollection()`, `updateCollectionItem()`, `removeFromCollection()`, `getCustomFields()`, `setCustomFields()`, `getCollectionValue()` +- **User Wantlist Methods** โ€“ `getUserWantlist()`, `addToWantlist()`, `updateWantlistItem()`, `removeFromWantlist()` +- **User Lists Methods** โ€“ `getUserLists()`, `getUserList()` +- **Marketplace Methods** โ€“ `getUserInventory()`, `getMarketplaceListing()`, `createMarketplaceListing()`, `updateMarketplaceListing()`, `deleteMarketplaceListing()`, `getMarketplaceFee()`, `getMarketplaceFeeByCurrency()`, `getMarketplacePriceSuggestions()`, `getMarketplaceStats()`, `getMarketplaceOrder()`, `getMarketplaceOrders()`, `updateMarketplaceOrder()`, `getMarketplaceOrderMessages()`, `addMarketplaceOrderMessage()` +- **Inventory Export Methods** โ€“ `createInventoryExport()`, `listInventoryExports()`, `getInventoryExport()`, `downloadInventoryExport()` +- **Inventory Upload Methods** โ€“ `addInventoryUpload()`, `changeInventoryUpload()`, `deleteInventoryUpload()`, `listInventoryUploads()`, `getInventoryUpload()` -*All 60 Discogs API endpoints are supported with clean documentation โ€” see [Discogs API Documentation](https://www.discogs.com/developers/) for complete method reference* +> [!NOTE] +> Complete method documentation and endpoint parameters can be found in the [Discogs API Documentation](https://www.discogs.com/developers/). -## ๐Ÿ“‹ Requirements +--- -- php ^8.1 -- symfony ^6.4 | ^7.0 | ^8.0 -- calliostro/php-discogs-api ^4.0 +## ๐Ÿ“‹ Requirements -## ๐Ÿ”ง Service Integration +- **PHP** `^8.1` (tested on PHP 8.1โ€“8.6) +- **Symfony** `^6.4 || ^7.0 || ^8.0` +- **calliostro/php-discogs-api** `^4.1` -```php -client->getArtist(artistId: $artistId); - $releases = $this->client->listArtistReleases( - artistId: $artistId, - perPage: 10 - ); - - return [ - 'artist' => $artist, - 'releases' => $releases['releases'], - ]; - } +You can customize or disable this behavior in `config/packages/calliostro_discogs.yaml`: - public function addToMyCollection(int $releaseId): void - { - // Requires Personal Access Token - $this->client->addToCollection( - username: 'your-username', // Replace with actual username - folderId: 1, // "Uncategorized" folder - releaseId: $releaseId - ); - } -} +```yaml +calliostro_discogs: + auto_retry: true # default: true + max_retries: 3 # default: 3 ``` -## โšก Rate Limiting (Optional) +### Symfony Rate Limiter (Proactive, Optional) -For high-volume applications, use the powerful [symfony/rate-limiter](https://symfony.com/doc/current/rate_limiter.html) component: +For high-volume batch processing, background workers, or scraping tasks, use `symfony/rate-limiter` to throttle outgoing requests client-side before sending them: ```bash composer require symfony/rate-limiter ``` -### 1. Configure Rate Limiter +#### 1. Configure the Rate Limiter ```yaml # config/packages/rate_limiter.yaml rate_limiter: discogs_api: policy: 'sliding_window' - limit: 25 # Safe for both anonymous and authenticated access + limit: 25 # 25 for anonymous access, up to 60 for authenticated access interval: '1 minute' ``` -### 2. Configure Bundle +#### 2. Assign to the Bundle ```yaml # config/packages/calliostro_discogs.yaml @@ -206,34 +199,41 @@ calliostro_discogs: rate_limiter: discogs_api ``` -**Choose your rate limit based on your authentication:** - -- **Anonymous access:** Use 25/min (as shown above) -- **Authenticated only:** Change limit to 60 for maximum performance +--- -The bundle uses a Guzzle middleware that automatically handles rate limiting by: +## ๐Ÿงช Development & Testing Guide -- Intercepting outgoing requests before they're sent -- Checking rate limit availability using Symfony's RateLimiter -- Automatically waiting when limits are exceeded (using microsecond precision) -- Retrying requests after the appropriate delay +See [DEVELOPMENT.md](DEVELOPMENT.md) for detailed setup instructions, test suite commands, static analysis, and contribution guidelines. -This seamless integration ensures your application never exceeds API limits without requiring any code changes. Higher rates may result in HTTP 429 responses if rate limiting is not configured. +--- ## ๐Ÿค Contributing -Contributions are welcome! Please see [DEVELOPMENT.md](DEVELOPMENT.md) for detailed setup instructions, testing guide, and development workflow. +Contributions are welcome! Please ensure that all tests pass and coding standards are maintained: + +```bash +composer cs-fix +composer analyse +composer test +``` + +--- ## ๐Ÿ“„ License This project is licensed under the MIT License โ€” see the [LICENSE](LICENSE) file for details. -## ๐Ÿ™ Acknowledgments +--- + +## โš–๏ธ Disclaimer -- [Discogs](https://www.discogs.com/) for providing the excellent music database API -- [Symfony](https://symfony.com) for the robust framework and DI container -- [calliostro/php-discogs-api](https://github.com/calliostro/php-discogs-api) for the modern client library +Discogs is a registered trademark of Zink Media, LLC. This project is an independent, unofficial open-source library and is not affiliated with, endorsed by, or sponsored by Discogs or Zink Media, LLC. --- -> **โญ Star this repo** if you find it useful! It helps others discover this lightweight solution. +## ๐Ÿ™ Acknowledgments + +- [Discogs](https://www.discogs.com/) for providing the database and API. +- [Symfony](https://symfony.com) for the web framework and dependency injection container. +- Underlying client: [`calliostro/php-discogs-api`](https://github.com/calliostro/php-discogs-api). +- Sister Symfony bundles: [`calliostro/spotify-web-api-bundle`](https://github.com/calliostro/spotify-web-api-bundle), [`calliostro/last-fm-client-bundle`](https://github.com/calliostro/last-fm-client-bundle), and [`calliostro/musicbrainz-bundle`](https://github.com/calliostro/musicbrainz-bundle). diff --git a/composer.json b/composer.json index d1d3073..3e2f5cd 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "calliostro/discogs-bundle", - "description": "Ultra-lightweight Symfony bundle for the Discogs API โ€” vinyl, music data & integration made easy", + "description": "Lightweight Symfony bundle for the Discogs API with autowiring, resilience, and rate limiting support.", "type": "symfony-bundle", "keywords": [ "symfony-bundle", @@ -26,20 +26,25 @@ ], "require": { "php": "^8.1", - "calliostro/php-discogs-api": "^4.0", - "symfony/config": "^6.4|^7.0|^8.0", - "symfony/dependency-injection": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0" + "calliostro/php-discogs-api": "^4.1", + "symfony/config": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0" }, "suggest": { - "symfony/rate-limiter": "For advanced rate limiting functionality" + "symfony/rate-limiter": "For proactive client-side rate limiting and traffic shaping" }, "require-dev": { - "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", - "phpstan/phpstan": "^2.1", - "friendsofphp/php-cs-fixer": "^3.87", - "symfony/rate-limiter": "^6.4|^7.0|^8.0", - "symfony/security-core": "^6.4|^7.0|^8.0" + "friendsofphp/php-cs-fixer": "^3.0", + "phpstan/phpstan": "^2.0", + "symfony/phpunit-bridge": "^6.4 || ^7.0 || ^8.0", + "symfony/rate-limiter": "^6.4 || ^7.0 || ^8.0", + "symfony/security-core": "^6.4 || ^7.0 || ^8.0" + }, + "config": { + "optimize-autoloader": true, + "preferred-install": "dist", + "sort-packages": true }, "autoload": { "psr-4": { @@ -59,10 +64,8 @@ "test-coverage-all": "vendor/bin/simple-phpunit --testsuite=\"All Tests\" --coverage-html coverage --coverage-clover coverage.xml", "cs": "vendor/bin/php-cs-fixer fix --dry-run --diff --verbose", "cs-fix": "vendor/bin/php-cs-fixer fix --verbose", - "analyse": "vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony8-baseline.neon", - "analyse-legacy": "vendor/bin/phpstan analyse src --level=8 --configuration phpstan/symfony-legacy-baseline.neon" + "analyse": "vendor/bin/phpstan analyse" }, "minimum-stability": "dev", "prefer-stable": true - } diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..4caae1b --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,15 @@ +parameters: + level: 8 + paths: + - src + tmpDir: .phpunit.cache/phpstan + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + ignoreErrors: + - identifier: method.alreadyNarrowedType + - identifier: function.alreadyNarrowedType + - identifier: missingType.generics + - + message: '#^Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition\:\:children\(\)\.$#' + identifier: method.notFound + path: src/DependencyInjection/Configuration.php diff --git a/phpstan/symfony-legacy-baseline.neon b/phpstan/symfony-legacy-baseline.neon deleted file mode 100644 index a5b8502..0000000 --- a/phpstan/symfony-legacy-baseline.neon +++ /dev/null @@ -1,7 +0,0 @@ -parameters: - ignoreErrors: - - - message: '#^Call to an undefined method Symfony\\Component\\Config\\Definition\\Builder\\NodeDefinition\:\:children\(\)\.$#' - identifier: method.notFound - count: 1 - path: ../src/DependencyInjection/Configuration.php diff --git a/phpstan/symfony74-baseline.neon b/phpstan/symfony74-baseline.neon deleted file mode 100644 index e5a3d87..0000000 --- a/phpstan/symfony74-baseline.neon +++ /dev/null @@ -1,7 +0,0 @@ -parameters: - ignoreErrors: - - - message: '#^Method Calliostro\\DiscogsBundle\\DependencyInjection\\Configuration\:\:getConfigTreeBuilder\(\) return type with generic class Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder does not specify its types\: T$#' - identifier: missingType.generics - count: 1 - path: ../src/DependencyInjection/Configuration.php \ No newline at end of file diff --git a/phpstan/symfony8-baseline.neon b/phpstan/symfony8-baseline.neon deleted file mode 100644 index e5a3d87..0000000 --- a/phpstan/symfony8-baseline.neon +++ /dev/null @@ -1,7 +0,0 @@ -parameters: - ignoreErrors: - - - message: '#^Method Calliostro\\DiscogsBundle\\DependencyInjection\\Configuration\:\:getConfigTreeBuilder\(\) return type with generic class Symfony\\Component\\Config\\Definition\\Builder\\TreeBuilder does not specify its types\: T$#' - identifier: missingType.generics - count: 1 - path: ../src/DependencyInjection/Configuration.php \ No newline at end of file diff --git a/src/DependencyInjection/CalliostroDiscogsExtension.php b/src/DependencyInjection/CalliostroDiscogsExtension.php index 4081340..ca4285d 100644 --- a/src/DependencyInjection/CalliostroDiscogsExtension.php +++ b/src/DependencyInjection/CalliostroDiscogsExtension.php @@ -26,7 +26,9 @@ public function load(array $configs, ContainerBuilder $container): void $config = $this->processConfiguration($configuration, $configs); // Load services configuration - $this->loadServices($container); // Configure client based on authentication method + $this->loadServices($container); + + // Configure client based on authentication method $this->configureClient($container, $config); } @@ -38,7 +40,7 @@ private function configureClient(ContainerBuilder $container, array $config): vo $clientDefinition = $container->getDefinition('calliostro_discogs.discogs_client'); // Create a factory service that will handle validation at runtime - $factoryDefinition = $container->register('calliostro_discogs.client_factory', 'Calliostro\\DiscogsBundle\\DependencyInjection\\DiscogsClientFactory'); + $container->register('calliostro_discogs.client_factory', DiscogsClientFactory::class); // Set the client to use our custom factory $clientDefinition->setFactory([new Reference('calliostro_discogs.client_factory'), 'createClient']); @@ -64,6 +66,14 @@ private function getClientOptions(ContainerBuilder $container, array $config): a $options['headers'] = ['User-Agent' => $config['user_agent']]; } + if (isset($config['auto_retry'])) { + $options['auto_retry'] = $config['auto_retry']; + } + + if (isset($config['max_retries'])) { + $options['max_retries'] = $config['max_retries']; + } + // Configure rate limiting if requested if (!empty($config['rate_limiter'])) { $this->configureSymfonyRateLimiter($container, $config['rate_limiter'], $options); @@ -108,7 +118,7 @@ private function configureSymfonyRateLimiter(ContainerBuilder $container, string */ private function loadServices(ContainerBuilder $container): void { - $fileLocator = new FileLocator(__DIR__.'/../Resources/config'); + $fileLocator = new FileLocator(__DIR__ . '/../Resources/config'); $loader = new PhpFileLoader($container, $fileLocator); $loader->load('services.php'); } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 4e998e0..bf69347 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -29,15 +29,25 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultNull() ->info('HTTP User-Agent header for API requests (optional)') ->validate() - ->ifTrue(fn ($v) => \is_string($v) && \strlen($v) > 200) + ->ifTrue(static fn ($v) => \is_string($v) && \strlen($v) > 200) ->thenInvalid('User-Agent cannot be longer than 200 characters') ->end() ->end() + ->booleanNode('auto_retry') + ->defaultTrue() + ->info('Automatically wait and retry on 429 and 503 responses') + ->end() + ->integerNode('max_retries') + ->defaultValue(3) + ->min(0) + ->max(10) + ->info('Maximum number of retry attempts for 429/503 responses') + ->end() ->scalarNode('rate_limiter') ->defaultNull() ->info('Symfony RateLimiterFactory service ID for advanced rate limiting (requires symfony/rate-limiter)') ->validate() - ->ifTrue(fn ($v) => \is_string($v) && '' === trim($v)) + ->ifTrue(static fn ($v) => \is_string($v) && '' === trim($v)) ->thenInvalid('Rate limiter service ID cannot be empty') ->end() ->end() diff --git a/tests/Unit/CalliostroDiscogsExtensionTest.php b/tests/Unit/CalliostroDiscogsExtensionTest.php index 9008e97..2712b4c 100644 --- a/tests/Unit/CalliostroDiscogsExtensionTest.php +++ b/tests/Unit/CalliostroDiscogsExtensionTest.php @@ -97,6 +97,7 @@ public function testLoadWithConsumerKeyAndSecretOnly(): void $this->assertTrue($container->hasDefinition('calliostro_discogs.client_factory')); $definition = $container->getDefinition('calliostro_discogs.discogs_client'); $factory = $definition->getFactory(); + $this->assertIsArray($factory); $this->assertEquals('createClient', $factory[1]); $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 4); // Check that consumer credentials are at the correct positions @@ -117,6 +118,7 @@ public function testLoadWithoutRateLimiter(): void $this->assertTrue($container->hasDefinition('calliostro_discogs.client_factory')); $definition = $container->getDefinition('calliostro_discogs.discogs_client'); $factory = $definition->getFactory(); + $this->assertIsArray($factory); $this->assertEquals('createClient', $factory[1]); } @@ -137,6 +139,7 @@ public function testLoadWithPersonalAccessToken(): void $this->assertTrue($container->hasDefinition('calliostro_discogs.client_factory')); $definition = $container->getDefinition('calliostro_discogs.discogs_client'); $factory = $definition->getFactory(); + $this->assertIsArray($factory); $this->assertEquals('createClient', $factory[1]); $this->assertDefinitionArgumentCount($container, 'calliostro_discogs.discogs_client', 4); $this->assertDefinitionArgumentEquals($container, 'calliostro_discogs.discogs_client', 0, 'test_token_123'); @@ -159,6 +162,7 @@ public function testLoadWithCustomUserAgent(): void $this->assertTrue($container->hasDefinition('calliostro_discogs.client_factory')); $definition = $container->getDefinition('calliostro_discogs.discogs_client'); $factory = $definition->getFactory(); + $this->assertIsArray($factory); $this->assertEquals('createClient', $factory[1]); $arguments = $definition->getArguments(); $options = $arguments[3]; // Options are at index 3 @@ -189,10 +193,35 @@ public function testLoadWithPersonalAccessTokenAndUserAgent(): void $definition = $container->getDefinition('calliostro_discogs.discogs_client'); $arguments = $definition->getArguments(); $options = $arguments[3]; // Options are now at index 3 + $this->assertIsArray($options); $this->assertArrayHasKey('headers', $options); $this->assertEquals('TestApp/1.0', $options['headers']['User-Agent']); } + public function testLoadWithRetryOptions(): void + { + $container = $this->createContainerBuilder(); + $extension = new CalliostroDiscogsExtension(); + + $config = [ + [ + 'auto_retry' => false, + 'max_retries' => 5, + ], + ]; + + $extension->load($config, $container); + + $definition = $container->getDefinition('calliostro_discogs.discogs_client'); + $arguments = $definition->getArguments(); + $options = $arguments[3]; + $this->assertIsArray($options); + $this->assertArrayHasKey('auto_retry', $options); + $this->assertFalse($options['auto_retry']); + $this->assertArrayHasKey('max_retries', $options); + $this->assertSame(5, $options['max_retries']); + } + public function testRateLimiterIntegration(): void { if (!class_exists('Symfony\\Component\\RateLimiter\\RateLimiterFactory')) { @@ -218,6 +247,7 @@ public function testRateLimiterIntegration(): void $definition = $container->getDefinition('calliostro_discogs.discogs_client'); $arguments = $definition->getArguments(); $options = $arguments[3] ?? []; // Fourth argument is now the options array + $this->assertIsArray($options); // Should have handler option pointing to rate limiter stack $this->assertArrayHasKey('handler', $options); diff --git a/tests/Unit/DependencyInjection/ConfigurationTest.php b/tests/Unit/DependencyInjection/ConfigurationTest.php index 57383e3..44dd4be 100644 --- a/tests/Unit/DependencyInjection/ConfigurationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationTest.php @@ -24,6 +24,8 @@ public function testEmptyConfiguration(): void $this->assertArrayNotHasKey('consumer_secret', $config); $this->assertNull($config['user_agent']); $this->assertNull($config['rate_limiter']); + $this->assertTrue($config['auto_retry']); + $this->assertSame(3, $config['max_retries']); } public function testConfigurationWithUserAgent(): void @@ -85,6 +87,21 @@ public function testRateLimiterBasicConfiguration(): void $this->assertArrayNotHasKey('throttle', $config); } + public function testRetryConfiguration(): void + { + $configs = [ + [ + 'auto_retry' => false, + 'max_retries' => 5, + ], + ]; + + $config = $this->processor->processConfiguration($this->configuration, $configs); + + $this->assertFalse($config['auto_retry']); + $this->assertSame(5, $config['max_retries']); + } + public function testCompleteConfiguration(): void { $configs = [ @@ -94,6 +111,8 @@ public function testCompleteConfiguration(): void 'consumer_secret' => 'valid_consumer_secret_12345', 'personal_access_token' => 'BillieEilishToken2024_123456789', 'rate_limiter' => 'my_rate_limiter', + 'auto_retry' => true, + 'max_retries' => 4, ], ]; @@ -104,6 +123,8 @@ public function testCompleteConfiguration(): void $this->assertEquals('valid_consumer_secret_12345', $config['consumer_secret']); $this->assertEquals('BillieEilishToken2024_123456789', $config['personal_access_token']); $this->assertEquals('my_rate_limiter', $config['rate_limiter']); + $this->assertTrue($config['auto_retry']); + $this->assertSame(4, $config['max_retries']); } public function testMultipleConfigurationMerging(): void @@ -112,11 +133,13 @@ public function testMultipleConfigurationMerging(): void [ 'user_agent' => 'FirstApp/1.0', 'consumer_key' => 'first_key', + 'max_retries' => 2, ], [ 'user_agent' => 'SecondApp/2.0', 'consumer_secret' => 'second_secret', 'rate_limiter' => 'my_rate_limiter', + 'max_retries' => 6, ], ]; @@ -127,6 +150,7 @@ public function testMultipleConfigurationMerging(): void $this->assertEquals('first_key', $config['consumer_key']); // From first config $this->assertEquals('second_secret', $config['consumer_secret']); // From second config $this->assertEquals('my_rate_limiter', $config['rate_limiter']); // From second config + $this->assertSame(6, $config['max_retries']); } public function testRateLimiterConfiguration(): void diff --git a/tests/Unit/DependencyInjection/ConfigurationValidationTest.php b/tests/Unit/DependencyInjection/ConfigurationValidationTest.php index 026f161..209c901 100644 --- a/tests/Unit/DependencyInjection/ConfigurationValidationTest.php +++ b/tests/Unit/DependencyInjection/ConfigurationValidationTest.php @@ -93,12 +93,40 @@ public function testTooLongUserAgentFails(): void $this->processor->processConfiguration($this->configuration, $configs); } + public function testMaxRetriesBelowMinFails(): void + { + $this->expectException(InvalidConfigurationException::class); + + $configs = [ + [ + 'max_retries' => -1, + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + + public function testMaxRetriesAboveMaxFails(): void + { + $this->expectException(InvalidConfigurationException::class); + + $configs = [ + [ + 'max_retries' => 11, + ], + ]; + + $this->processor->processConfiguration($this->configuration, $configs); + } + public function testValidConfiguration(): void { $configs = [ [ 'personal_access_token' => 'BillieEilishFan2024Token123456789', 'user_agent' => 'MyMusicApp/2.0 +https://example.com', + 'auto_retry' => true, + 'max_retries' => 5, ], ]; @@ -106,6 +134,8 @@ public function testValidConfiguration(): void $this->assertEquals('BillieEilishFan2024Token123456789', $config['personal_access_token']); $this->assertEquals('MyMusicApp/2.0 +https://example.com', $config['user_agent']); + $this->assertTrue($config['auto_retry']); + $this->assertSame(5, $config['max_retries']); } public function testEnvironmentVariableSyntaxAllowed(): void diff --git a/tests/Unit/Middleware/RateLimiterMiddlewareTest.php b/tests/Unit/Middleware/RateLimiterMiddlewareTest.php index a429dc7..6124899 100644 --- a/tests/Unit/Middleware/RateLimiterMiddlewareTest.php +++ b/tests/Unit/Middleware/RateLimiterMiddlewareTest.php @@ -105,7 +105,9 @@ public function testMiddlewareIsCallable(): void $this->assertTrue(\is_callable($middleware)); // Test that calling it returns another callable - $handler = function () { return 'test'; }; + $handler = function () { + return 'test'; + }; $wrappedHandler = $middleware($handler); $this->assertTrue(\is_callable($wrappedHandler)); diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php index 053a03e..16ed485 100644 --- a/tests/Unit/UnitTestCase.php +++ b/tests/Unit/UnitTestCase.php @@ -29,6 +29,8 @@ protected function createContainerBuilder(): ContainerBuilder /** * Assert that a service has the expected type. + * + * @param class-string $expectedClass */ protected function assertServiceInstanceOf(ContainerInterface $container, string $serviceId, string $expectedClass): void { @@ -78,7 +80,7 @@ protected function assertDefinitionArgumentCount(ContainerBuilder $container, st /** * Assert definition argument has expected value. */ - protected function assertDefinitionArgumentEquals(ContainerBuilder $container, string $serviceId, int $argumentIndex, $expectedValue): void + protected function assertDefinitionArgumentEquals(ContainerBuilder $container, string $serviceId, int $argumentIndex, mixed $expectedValue): void { $this->assertDefinitionExists($container, $serviceId); $definition = $container->getDefinition($serviceId);