diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1918778..b00fde8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,12 +3,10 @@ name: CI on: push: branches: - - main - master - develop pull_request: branches: - - main - master - develop @@ -47,8 +45,5 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-interaction --no-progress - - name: Run lint suite - run: composer lint:all - - - name: Run tests - run: composer test + - name: Run project checks + run: composer check diff --git a/AGENTS.md b/AGENTS.md index 81e52b7..482484c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,27 @@ REST flow: - Token store, file handling, upload, OAuth signing, parser, and registry changes need focused edge tests. - Do not commit secrets, real access tokens, or snapshots containing credentials. +## Performance Rules + +- Cache safety comes before cache features. +- Never cache auth, OAuth, mutation, private authenticated, upload, replace, or upload ticket workflows. +- Optional REST caching must stay limited to public cacheable GET calls and must be disabled by default unless a cache adapter is passed. +- Do not add concurrency unless the current transport architecture clearly supports it and tests prove the behavior. +- Prefer lazy pagination helpers over loading all pages into memory. +- Upload and replace should stream files where possible and close file handles after transport handoff. +- Benchmark only when it would produce meaningful evidence for a real performance decision. + +## Git Flow + +- Normal implementation work branches from the latest `develop`. +- Open feature and fix PRs back into `develop`. +- Release branches start from `develop` as `release/`. +- Release PRs target `master`; tags and GitHub releases are created from `master`. +- After release or hotfix merges to `master`, merge `master` back into `develop`. +- Do not commit directly to `master` or `develop`. +- Do not delete unmerged branches silently. +- Never delete protected branches: `master` and `develop`. + ## Quality Commands ```bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 763cd10..d6ba991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ This project follows semantic versioning where practical. ## Unreleased +## v1.0.0 - 2026-05-14 + ### Added - GitHub Actions CI workflow for Composer validation, linting, and tests. @@ -13,12 +15,22 @@ This project follows semantic versioning where practical. - Method registry verification tooling and maintenance docs. - Runnable examples for raw REST, search, photo info, OAuth, upload, replace, async tickets, mock transport, and custom cache usage. - Release readiness and repository metadata documentation. +- Full registry and service-wrapper coverage for 224 official Flickr REST API methods. +- OAuth 1.0a authentication, token storage, raw REST calls, upload, replace, and async ticket polling workflows. +- DTO-first helpers for high-value photo, people, photoset, upload, replace, and response workflows. +- Optional public GET caching through package cache contracts and PSR-16 adapter support. +- Lazy `photos()->searchPages()` pagination helper. +- AI contributor workflow, CI/CD, secret-handling, method-registry, and repository metadata docs. ### Changed - Composer lint scripts now run php-cs-fixer alongside Pint, PHPCS, PHPStan, and PHPMD. - Documentation index and root README now better describe SDK identity, testing, release readiness, and non-Laravel scope. +- Cache metadata is conservative for auth, OAuth, upload ticket, authenticated, permissioned, and mutation methods. +- Upload and replace multipart requests now close file stream handles after transport requests complete. +- Test coverage was raised above the 95% statement coverage release target. ### Security - Examples read credentials from environment variables and avoid embedded secrets. +- Normal test and CI paths keep real Flickr API calls opt-in behind `FLICKR_REAL_TESTS=true`. diff --git a/README.md b/README.md index 56b057f..053e18b 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,19 @@ $response = $flickr->photos()->search(SearchPhotosData::from([ `flickr.photos.search` can run unauthenticated for public photos. Private and semi-private results require OAuth read permission. +For lazy public search pagination, use `searchPages()`: + +```php +use JOOservices\Flickr\DTO\Common\PaginationOptionsData; + +foreach ($flickr->photos()->searchPages( + SearchPhotosData::from(['text' => 'sunset']), + new PaginationOptionsData(maxPages: 3, perPage: 50), +) as $page) { + // $page is ApiResponseData +} +``` + ## Raw API Fallback ```php @@ -129,7 +142,7 @@ $result = $flickr->uploads()->replace(ReplacePhotoData::from([ ])); ``` -Upload and replace require OAuth write permission. Delete requires delete permission. +Upload and replace require OAuth write permission. Delete requires delete permission. The SDK builds multipart requests with a readable file stream and closes that handle after the transport request completes. ## Error Handling @@ -137,7 +150,18 @@ Normal API responses are mapped to `ApiResponseData`. Flickr `stat=fail` respons ## Cache -V1 includes `NullCache`, `Psr16Cache`, and `CacheKeyResolver`, but raw HTTP caching is disabled by default. Mutation, auth, upload, replace, and authenticated private calls are never cached by default. +V1 includes `NullCache`, `Psr16Cache`, and `CacheKeyResolver`. Runtime caching is disabled by default because `FlickrFactory` uses `NullCache` unless a cache adapter is passed. + +When a cache adapter is passed, only public cacheable GET REST calls can be cached. Mutation, auth, OAuth, upload, replace, upload ticket polling, authenticated options, auth-required methods, POST methods, and Flickr `stat=fail` responses are never cached by default. + +```php +use JOOservices\Flickr\Cache\Psr16Cache; + +$flickr = FlickrFactory::make( + config: $config, + cache: new Psr16Cache($psr16Cache), +); +``` ## XML Support diff --git a/ai/skills/README.md b/ai/skills/README.md new file mode 100644 index 0000000..73c99c1 --- /dev/null +++ b/ai/skills/README.md @@ -0,0 +1,15 @@ +# JOOservices Flickr AI Skills + +This repository keeps lightweight AI contributor guidance in `AGENTS.md` and `docs/04-development/08-ai-contributor-workflow.md`. + +Use this directory as the local entry point when an agent expects repo-level AI skill files. Keep it small unless the repository adopts the fuller `jooservices/dto` skill-pack structure. + +Current rules: + +- inspect actual source before non-trivial edits +- keep the package framework-agnostic +- use official Flickr docs for API behavior +- keep upload and replace separate from REST calls +- keep raw fallback support for unknown Flickr methods +- follow `jooservices/dto` style for DTO/Data objects +- run repository quality commands before claiming completion diff --git a/composer.lock b/composer.lock index 9dbeae8..9454c51 100644 --- a/composer.lock +++ b/composer.lock @@ -552,7 +552,7 @@ }, { "name": "illuminate/bus", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/bus.git", @@ -605,7 +605,7 @@ }, { "name": "illuminate/cache", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/cache.git", @@ -667,16 +667,16 @@ }, { "name": "illuminate/collections", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "17b082d0c66fb030f22d5bdd62ba652c045ff522" + "reference": "dda57c96a3e2620e4218ba54c818d55c9c2fcd4f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/17b082d0c66fb030f22d5bdd62ba652c045ff522", - "reference": "17b082d0c66fb030f22d5bdd62ba652c045ff522", + "url": "https://api.github.com/repos/illuminate/collections/zipball/dda57c96a3e2620e4218ba54c818d55c9c2fcd4f", + "reference": "dda57c96a3e2620e4218ba54c818d55c9c2fcd4f", "shasum": "" }, "require": { @@ -684,8 +684,8 @@ "illuminate/contracts": "^13.0", "illuminate/macroable": "^13.0", "php": "^8.3", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", "symfony/polyfill-php86": "^1.36" }, "suggest": { @@ -723,11 +723,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-04-28T17:17:15+00:00" + "time": "2026-05-05T20:55:51+00:00" }, { "name": "illuminate/conditionable", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -773,16 +773,16 @@ }, { "name": "illuminate/container", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/container.git", - "reference": "66f69751e60ca8aacd965d490bece81a59c79c17" + "reference": "71daf6ee3788e6930e7eb8454d840f1ccfd6a313" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/container/zipball/66f69751e60ca8aacd965d490bece81a59c79c17", - "reference": "66f69751e60ca8aacd965d490bece81a59c79c17", + "url": "https://api.github.com/repos/illuminate/container/zipball/71daf6ee3788e6930e7eb8454d840f1ccfd6a313", + "reference": "71daf6ee3788e6930e7eb8454d840f1ccfd6a313", "shasum": "" }, "require": { @@ -790,8 +790,8 @@ "illuminate/reflection": "^13.0", "php": "^8.3", "psr/container": "^1.1.1 || ^2.0.1", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33" + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36" }, "provide": { "psr/container-implementation": "1.1 || 2.0" @@ -831,20 +831,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-03-20T15:16:26+00:00" + "time": "2026-05-05T20:55:51+00:00" }, { "name": "illuminate/contracts", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", - "reference": "ce6f5561f88743639c76835ad82cfe39b468bdc8" + "reference": "71dba8668753f7c6ea862bc4258eba6513b592ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/ce6f5561f88743639c76835ad82cfe39b468bdc8", - "reference": "ce6f5561f88743639c76835ad82cfe39b468bdc8", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/71dba8668753f7c6ea862bc4258eba6513b592ec", + "reference": "71dba8668753f7c6ea862bc4258eba6513b592ec", "shasum": "" }, "require": { @@ -879,20 +879,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-04-28T17:16:27+00:00" + "time": "2026-05-06T18:50:26+00:00" }, { "name": "illuminate/database", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/database.git", - "reference": "b04b338f22d4625e78d1427d3eb68758c17a97eb" + "reference": "c0f1b78ed5e4cbdad0744181050aa689c477124f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/database/zipball/b04b338f22d4625e78d1427d3eb68758c17a97eb", - "reference": "b04b338f22d4625e78d1427d3eb68758c17a97eb", + "url": "https://api.github.com/repos/illuminate/database/zipball/c0f1b78ed5e4cbdad0744181050aa689c477124f", + "reference": "c0f1b78ed5e4cbdad0744181050aa689c477124f", "shasum": "" }, "require": { @@ -905,8 +905,9 @@ "illuminate/support": "^13.0", "laravel/serializable-closure": "^2.0.10", "php": "^8.3", - "symfony/polyfill-php84": "^1.34", - "symfony/polyfill-php85": "^1.34" + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36" }, "suggest": { "ext-filter": "Required to use the Postgres database driver.", @@ -951,11 +952,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-04T12:35:50+00:00" + "time": "2026-05-10T15:49:54+00:00" }, { "name": "illuminate/events", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/events.git", @@ -1010,7 +1011,7 @@ }, { "name": "illuminate/macroable", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -1056,7 +1057,7 @@ }, { "name": "illuminate/pipeline", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/pipeline.git", @@ -1108,7 +1109,7 @@ }, { "name": "illuminate/reflection", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/reflection.git", @@ -1159,16 +1160,16 @@ }, { "name": "illuminate/support", - "version": "v13.8.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "ff687db22aefef516efd3ea21d01664af332da38" + "reference": "3367c25b040788835fd043fbddb64bd5af659ec6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/ff687db22aefef516efd3ea21d01664af332da38", - "reference": "ff687db22aefef516efd3ea21d01664af332da38", + "url": "https://api.github.com/repos/illuminate/support/zipball/3367c25b040788835fd043fbddb64bd5af659ec6", + "reference": "3367c25b040788835fd043fbddb64bd5af659ec6", "shasum": "" }, "require": { @@ -1183,7 +1184,7 @@ "illuminate/reflection": "^13.0", "nesbot/carbon": "^3.8.4", "php": "^8.3", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php85": "^1.36", "voku/portable-ascii": "^2.0.2" }, "conflict": { @@ -1234,20 +1235,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-04T12:34:54+00:00" + "time": "2026-05-10T15:47:41+00:00" }, { "name": "jooservices/client", - "version": "1.2.2", + "version": "v1.3.0", "source": { "type": "git", "url": "https://github.com/jooservices/client.git", - "reference": "07ff2c9d094dd94e12f4f0d99597a272989aef76" + "reference": "cd49798adb7e84c15d7ffdabd8797daf3c9324ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jooservices/client/zipball/07ff2c9d094dd94e12f4f0d99597a272989aef76", - "reference": "07ff2c9d094dd94e12f4f0d99597a272989aef76", + "url": "https://api.github.com/repos/jooservices/client/zipball/cd49798adb7e84c15d7ffdabd8797daf3c9324ff", + "reference": "cd49798adb7e84c15d7ffdabd8797daf3c9324ff", "shasum": "" }, "require": { @@ -1292,25 +1293,36 @@ "email": "contact@jooservices.com" } ], - "description": "A robust, layered HTTP Client wrapper for JOOservices", + "description": "Strict, extensible PHP 8.5+ HTTP client wrapper for JOOservices", + "keywords": [ + "Guzzle", + "JOOservices", + "async", + "cache", + "circuit-breaker", + "dto", + "http-client", + "logging", + "retry" + ], "support": { "issues": "https://github.com/jooservices/client/issues", - "source": "https://github.com/jooservices/client/tree/v1.2.2" + "source": "https://github.com/jooservices/client" }, - "time": "2026-04-05T12:44:01+00:00" + "time": "2026-05-11T12:44:13+00:00" }, { "name": "jooservices/dto", - "version": "v1.0.5", + "version": "v1.0.6", "source": { "type": "git", "url": "https://github.com/jooservices/dto.git", - "reference": "33241004d643d0974fab69f0a8d627a1f7663d69" + "reference": "00f7e1355cdabd72a17e9be5cbf5aa485e18a82f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jooservices/dto/zipball/33241004d643d0974fab69f0a8d627a1f7663d69", - "reference": "33241004d643d0974fab69f0a8d627a1f7663d69", + "url": "https://api.github.com/repos/jooservices/dto/zipball/00f7e1355cdabd72a17e9be5cbf5aa485e18a82f", + "reference": "00f7e1355cdabd72a17e9be5cbf5aa485e18a82f", "shasum": "" }, "require": { @@ -1360,7 +1372,7 @@ "issues": "https://github.com/jooservices/dto/issues", "source": "https://github.com/jooservices/dto" }, - "time": "2026-04-20T12:35:51+00:00" + "time": "2026-05-11T03:09:39+00:00" }, { "name": "laravel/serializable-closure", @@ -1532,21 +1544,21 @@ }, { "name": "mongodb/mongodb", - "version": "2.2.0", + "version": "2.1.2", "source": { "type": "git", "url": "https://github.com/mongodb/mongo-php-library.git", - "reference": "bbb13f969e37e047fd822527543df55fdc1c9298" + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/bbb13f969e37e047fd822527543df55fdc1c9298", - "reference": "bbb13f969e37e047fd822527543df55fdc1c9298", + "url": "https://api.github.com/repos/mongodb/mongo-php-library/zipball/0a2472ba9cbb932f7e43a8770aedb2fc30612a67", + "reference": "0a2472ba9cbb932f7e43a8770aedb2fc30612a67", "shasum": "" }, "require": { "composer-runtime-api": "^2.0", - "ext-mongodb": "^2.2", + "ext-mongodb": "^2.1", "php": "^8.1", "psr/log": "^1.1.4|^2|^3", "symfony/polyfill-php85": "^1.32" @@ -1557,9 +1569,9 @@ "require-dev": { "doctrine/coding-standard": "^12.0", "phpunit/phpunit": "^10.5.35", - "rector/rector": "^2.3.4", + "rector/rector": "^2.1.4", "squizlabs/php_codesniffer": "^3.7", - "vimeo/psalm": "~6.14.2" + "vimeo/psalm": "6.5.*" }, "type": "library", "extra": { @@ -1603,9 +1615,9 @@ ], "support": { "issues": "https://github.com/mongodb/mongo-php-library/issues", - "source": "https://github.com/mongodb/mongo-php-library/tree/2.2.0" + "source": "https://github.com/mongodb/mongo-php-library/tree/2.1.2" }, - "time": "2026-02-11T11:39:56+00:00" + "time": "2025-10-06T12:12:40+00:00" }, { "name": "monolog/monolog", @@ -4870,16 +4882,16 @@ }, { "name": "phpunit/phpunit", - "version": "12.5.24", + "version": "12.5.25", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046" + "reference": "792c2980442dfce319226b88fa845b8b6de3b333" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d75dd30597caa80e72fad2ef7904601a30ef1046", - "reference": "d75dd30597caa80e72fad2ef7904601a30ef1046", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/792c2980442dfce319226b88fa845b8b6de3b333", + "reference": "792c2980442dfce319226b88fa845b8b6de3b333", "shasum": "" }, "require": { @@ -4948,7 +4960,7 @@ "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.24" + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.25" }, "funding": [ { @@ -4956,7 +4968,7 @@ "type": "other" } ], - "time": "2026-05-01T04:21:04+00:00" + "time": "2026-05-13T03:56:57+00:00" }, { "name": "psr/event-dispatcher", @@ -6819,16 +6831,16 @@ }, { "name": "symfony/console", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", "shasum": "" }, "require": { @@ -6885,7 +6897,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.9" + "source": "https://github.com/symfony/console/tree/v8.0.11" }, "funding": [ { @@ -6905,7 +6917,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/dependency-injection", @@ -7158,16 +7170,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.4.9", + "version": "v7.4.11", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "dcd8f96bcdc0f128ec406c765cc066c6035d1be3" + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/dcd8f96bcdc0f128ec406c765cc066c6035d1be3", - "reference": "dcd8f96bcdc0f128ec406c765cc066c6035d1be3", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", "shasum": "" }, "require": { @@ -7204,7 +7216,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.4.9" + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" }, "funding": [ { @@ -7224,7 +7236,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:18:21+00:00" + "time": "2026-05-11T16:38:44+00:00" }, { "name": "symfony/finder", @@ -7710,16 +7722,16 @@ }, { "name": "symfony/process", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { @@ -7751,7 +7763,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.8" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -7771,7 +7783,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { "name": "symfony/service-contracts", @@ -7928,16 +7940,16 @@ }, { "name": "symfony/string", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -7994,7 +8006,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.8" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -8014,7 +8026,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/var-exporter", diff --git a/docs/00-architecture/05-upload-and-replace.md b/docs/00-architecture/05-upload-and-replace.md index a8685eb..871c728 100644 --- a/docs/00-architecture/05-upload-and-replace.md +++ b/docs/00-architecture/05-upload-and-replace.md @@ -4,4 +4,6 @@ Upload and replace use dedicated endpoints on `https://up.flickr.com/services/up These flows are intentionally separate from normal REST API calls because Flickr sends binary files. The `photo` multipart field is excluded from OAuth signature generation; all other POST parameters are signed. +The multipart request uses a readable file stream for the `photo` part and closes the stream after the transport request completes. + Async upload or replace sends `async=1` and returns a ticket id. Poll ticket ids with `flickr.photos.upload.checkTickets`. diff --git a/docs/02-user-guide/02-photos.md b/docs/02-user-guide/02-photos.md index dd9f3f8..54152ed 100644 --- a/docs/02-user-guide/02-photos.md +++ b/docs/02-user-guide/02-photos.md @@ -11,3 +11,19 @@ Implemented V1 methods: - `addTags` - `removeTag` - `delete` + +## Lazy Search Pagination + +`searchPages()` yields `ApiResponseData` pages lazily and stops at Flickr's reported final page, an empty `photo` list when enabled, or the configured maximum page count. + +```php +use JOOservices\Flickr\DTO\Common\PaginationOptionsData; +use JOOservices\Flickr\DTO\Photos\SearchPhotosData; + +foreach ($flickr->photos()->searchPages( + SearchPhotosData::from(['text' => 'sunset']), + new PaginationOptionsData(maxPages: 3, perPage: 50), +) as $page) { + // inspect $page->data['photos']['photo'] +} +``` diff --git a/docs/02-user-guide/13-cache-and-error-handling.md b/docs/02-user-guide/13-cache-and-error-handling.md index c86b984..9c56be7 100644 --- a/docs/02-user-guide/13-cache-and-error-handling.md +++ b/docs/02-user-guide/13-cache-and-error-handling.md @@ -12,4 +12,14 @@ V1 ships cache contracts and adapters: - `Psr16Cache` - `CacheKeyResolver` -Raw HTTP caching is disabled by default. Mutation, auth, upload, replace, and authenticated private workflows must not be cached by default. +Raw HTTP caching is disabled by default. `FlickrFactory` uses `NullCache` unless a cache adapter is passed. + +When a cache adapter is passed, caching is limited to public cacheable GET REST calls. Cache bypasses apply to: + +- auth and OAuth methods +- upload, replace, and upload ticket polling +- POST, write, delete, and mutation methods +- auth-required methods and request options with `authenticated=true` +- Flickr `stat=fail` responses + +Use `RequestOptionsData(cache: CachePolicy::Disabled)` to bypass cache for an individual call. diff --git a/docs/04-development/01-code-style.md b/docs/04-development/01-code-style.md index accce18..590c9d7 100644 --- a/docs/04-development/01-code-style.md +++ b/docs/04-development/01-code-style.md @@ -1,3 +1,7 @@ # Code Style -Pint is the formatting authority. Keep DTOs constructor-driven, services small, and transport behind contracts. +Pint is the formatting authority. If Pint and another formatter disagree, Pint wins. + +Keep DTOs constructor-driven and aligned with `jooservices/dto` style. Services should stay small, translate friendly method calls to raw Flickr method calls, and leave transport behind contracts. + +Prefer enums or constants for Flickr domain values. Use public DTOs where they improve user-facing workflows, but keep the raw API fallback array-based so unknown future Flickr methods remain callable. diff --git a/docs/04-development/02-testing.md b/docs/04-development/02-testing.md index 5893175..5b3e5fb 100644 --- a/docs/04-development/02-testing.md +++ b/docs/04-development/02-testing.md @@ -1,3 +1,14 @@ # Testing Normal CI tests must not call Flickr. Real tests are skipped unless `FLICKR_REAL_TESTS=true` and credentials are present. + +Run focused tests while editing, then run the full local gate before claiming completion: + +```bash +composer lint:all +composer test +composer check +php tools/verify-method-registry.php +``` + +Registry, OAuth signing, token storage, cache metadata, upload, replace, parser, and file handling changes need focused edge-case tests. Do not commit real access tokens, API secrets, user media, or fixtures copied from private Flickr responses. diff --git a/docs/04-development/04-release.md b/docs/04-development/04-release.md index 829b212..d9926ed 100644 --- a/docs/04-development/04-release.md +++ b/docs/04-development/04-release.md @@ -2,6 +2,8 @@ Use this checklist before tagging a release. +Normal implementation branches start from `develop` and open PRs back to `develop`. Release branches start from `develop` as `release/` and open PRs to `master`. Tag and publish releases from `master`, then merge `master` back into `develop`. + ## Preflight ```bash @@ -51,6 +53,8 @@ Do not create tags or GitHub releases from automation unless explicitly authoriz Manual release flow: ```bash +git checkout master +git pull origin master git tag vX.Y.Z git push origin vX.Y.Z ``` diff --git a/docs/04-development/05-method-registry.md b/docs/04-development/05-method-registry.md index 8fc5c15..cd95c9d 100644 --- a/docs/04-development/05-method-registry.md +++ b/docs/04-development/05-method-registry.md @@ -5,6 +5,16 @@ The SDK tracks the official Flickr REST method index in two deterministic files: - `tests/Fixtures/official-flickr-methods.php` - `src/Metadata/methods.php` +Last verified against the official Flickr REST API index on 2026-05-14. + +Source URLs: + +- REST API index: https://www.flickr.com/services/api/ +- Upload API: https://www.flickr.com/services/api/upload.api.html +- Replace API: https://www.flickr.com/services/api/replace.api.html + +Upload and replace are separate binary workflows and are not counted as REST methods. `flickr.photos.upload.checkTickets` is a REST method and remains in the 224-method registry. + Normal CI must not scrape Flickr. Keep verification local and repeatable: ```bash @@ -15,11 +25,24 @@ vendor/bin/phpunit --filter OfficialMethodCoverageTest When Flickr adds or changes a method: 1. Check the official method page at `https://www.flickr.com/services/api/`. -2. Update `tests/Fixtures/official-flickr-methods.php`. +2. Regenerate or update `tests/Fixtures/official-flickr-methods.php` from REST method links only. 3. Update `src/Metadata/methods.php` with docs URL, HTTP method, auth requirement, OAuth permission, and cache policy. 4. Add or update the matching service wrapper. 5. Add a DTO or mapper only when the workflow benefits from typed data. 6. Add tests for the registry entry and wrapper behavior. 7. Keep raw fallback available for unknown future methods. -Upload and replace are intentionally outside normal REST generation because Flickr uses a binary upload endpoint. +One local way to compare the live index against the fixture is: + +```bash +php -r '$html=file_get_contents("https://www.flickr.com/services/api/"); preg_match_all("~href=\"/services/api/(flickr\\.[A-Za-z0-9_.]+)\\.html\"~", $html, $m); $live=array_values(array_unique($m[1])); $fixture=require "tests/Fixtures/official-flickr-methods.php"; echo "live=".count($live).PHP_EOL; echo "missing local: ".implode(",", array_diff($live, $fixture)).PHP_EOL; echo "extra local: ".implode(",", array_diff($fixture, $live)).PHP_EOL;' +``` + +Then run: + +```bash +php tools/verify-method-registry.php +vendor/bin/phpunit --filter OfficialMethodCoverageTest +``` + +Cache policy must be conservative. Auth methods, OAuth methods, upload ticket checks, authenticated calls, and mutation/write/delete methods must not be cacheable by default. This includes methods whose name starts with `add`, `edit`, `delete`, `remove`, `set`, `create`, `join`, `leave`, `post`, `approve`, `reject`, `subscribe`, `unsubscribe`, `rotate`, `correctLocation`, or `batchCorrectLocation`, plus any POST or permissioned method. diff --git a/docs/04-development/06-repository-metadata.md b/docs/04-development/06-repository-metadata.md index aef2cd5..43e4ea4 100644 --- a/docs/04-development/06-repository-metadata.md +++ b/docs/04-development/06-repository-metadata.md @@ -5,20 +5,20 @@ GitHub About metadata should identify this as a framework-agnostic PHP Flickr SD Suggested description: ```text -Framework-agnostic PHP 8.5+ SDK for Flickr API, OAuth 1.0a, upload, replace, and DTO-first workflows. +Framework-agnostic PHP 8.5+ SDK for Flickr REST API, OAuth 1.0a, upload, replace, and DTO-first workflows. ``` Suggested topics: ```text -php php85 flickr flickr-api sdk oauth oauth1 upload dto jooservices +php php85 sdk flickr flickr-api oauth oauth1 upload dto jooservices framework-agnostic api-client ``` Manual update command: ```bash gh repo edit jooservices/flickr \ - --description "Framework-agnostic PHP 8.5+ SDK for Flickr API, OAuth 1.0a, upload, replace, and DTO-first workflows." \ + --description "Framework-agnostic PHP 8.5+ SDK for Flickr REST API, OAuth 1.0a, upload, replace, and DTO-first workflows." \ --add-topic php \ --add-topic php85 \ --add-topic flickr \ @@ -28,7 +28,9 @@ gh repo edit jooservices/flickr \ --add-topic oauth1 \ --add-topic upload \ --add-topic dto \ - --add-topic jooservices + --add-topic jooservices \ + --add-topic framework-agnostic \ + --add-topic api-client ``` Do not set a homepage unless there is a real docs site or Packagist page. diff --git a/docs/04-development/07-ci-cd.md b/docs/04-development/07-ci-cd.md new file mode 100644 index 0000000..2f7128c --- /dev/null +++ b/docs/04-development/07-ci-cd.md @@ -0,0 +1,13 @@ +# CI/CD + +GitHub Actions runs on `master` and `develop` pushes and pull requests. Normal CI sets `FLICKR_REAL_TESTS=false`; real Flickr credentials must never be required for pull request validation. + +The PR gate is: + +```bash +composer validate --strict +composer install --prefer-dist --no-interaction --no-progress +composer check +``` + +`composer check` runs `composer lint:all` and `composer test`. Coverage remains available through `composer ci`, but it requires a working local or CI coverage driver and should not make normal PR validation fragile. diff --git a/docs/04-development/08-ai-contributor-workflow.md b/docs/04-development/08-ai-contributor-workflow.md new file mode 100644 index 0000000..0cf76a1 --- /dev/null +++ b/docs/04-development/08-ai-contributor-workflow.md @@ -0,0 +1,15 @@ +# AI Contributor Workflow + +Start with `AGENTS.md`, inspect the real source, and keep changes scoped to this framework-agnostic SDK. Do not add Laravel service providers, facades, routes, migrations, config publishing, or Artisan commands. + +For Flickr behavior, use the official Flickr docs as source of truth. For DTO/Data object style, follow `jooservices/dto` conventions without copying unrelated DTO-library features into this SDK. + +Before editing, identify the owning area: + +- REST methods: `src/Services`, `src/Contracts/Services`, `src/Metadata/methods.php` +- upload and replace: `src/Client/FlickrUploadClient.php`, `src/Services/UploadService.php`, upload DTOs +- transport: `src/Client/JooClientTransport.php` and client contracts +- DTOs and mappers: `src/DTO` and `src/Mappers` +- verification: `tests`, `tests/Fixtures`, and `tools/verify-method-registry.php` + +If requirements conflict with repository code, official Flickr docs, package scope, or test safety, stop and ask. diff --git a/docs/04-development/09-secret-scanning.md b/docs/04-development/09-secret-scanning.md new file mode 100644 index 0000000..e5286c4 --- /dev/null +++ b/docs/04-development/09-secret-scanning.md @@ -0,0 +1,14 @@ +# Secret Scanning + +Do not commit Flickr API keys, shared secrets, OAuth request tokens, OAuth access tokens, private user ids tied to credentials, or real response snapshots containing credentials. + +Examples and real integration tests must read secrets from environment variables. Normal CI must keep `FLICKR_REAL_TESTS=false`. + +Before release or PR handoff, inspect staged changes for accidental credentials: + +```bash +git diff --cached +git status --short +``` + +If a secret is committed, treat it as compromised and rotate it. Do not hide the incident with a normal cleanup commit. diff --git a/docs/05-maintenance/01-risks-legacy-and-gaps.md b/docs/05-maintenance/01-risks-legacy-and-gaps.md index 6bbad72..0d54e59 100644 --- a/docs/05-maintenance/01-risks-legacy-and-gaps.md +++ b/docs/05-maintenance/01-risks-legacy-and-gaps.md @@ -4,5 +4,28 @@ Current gaps: - All official Flickr REST methods have wrappers, but most wrappers accept associative parameter arrays rather than dedicated request DTOs. - XML normal API support is basic and JSON is the primary supported response format. -- Cache adapters exist, but raw API caching is not wired by default. - Upload file type validation is intentionally limited to local file safety; Flickr remains the authority for accepted media formats. +- Optional runtime caching is intentionally limited to public cacheable GET REST calls. +- Pagination helpers currently cover `photos()->searchPages()` only. +- No concurrency helper is exposed because the current public transport contract only models single requests. + +## DTO-first roadmap + +Do not DTO-wrap all 224 methods in one sweep. Expand typed request and response objects where the workflow is high-value and the mapper can be tested well. + +Priority candidates: + +- `flickr.photos.search` +- `flickr.photos.getInfo` +- `flickr.photos.getSizes` +- `flickr.photos.getExif` +- `flickr.people.getInfo` +- `flickr.people.getPhotos` +- `flickr.photosets.getList` +- `flickr.photosets.getPhotos` +- `flickr.favorites.getList` +- `flickr.groups.pools.getPhotos` +- `flickr.tags.getHotList` +- `flickr.places.*` + +Keep raw fallback support intact while this roadmap advances. diff --git a/docs/05-maintenance/02-docs-changelog.md b/docs/05-maintenance/02-docs-changelog.md index 63820e0..0b5fa72 100644 --- a/docs/05-maintenance/02-docs-changelog.md +++ b/docs/05-maintenance/02-docs-changelog.md @@ -3,3 +3,7 @@ Initial V1 documentation added for raw API, OAuth, photos, photosets, people, upload, replace, async tickets, testing, and known gaps. Updated to document full 224-method official Flickr API wrapper coverage. + +Updated 2026-05-14 to document method registry verification sources, cache safety expectations, Git flow, CI command alignment, repository metadata guidance, AI contributor workflow, secret handling, and the DTO-first expansion roadmap. + +Updated 2026-05-14 to document optional public GET caching, lazy photo search pagination, upload stream cleanup, and remaining performance boundaries. diff --git a/docs/README.md b/docs/README.md index 3c326e3..393a1c9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +56,9 @@ Official Flickr API docs remain the source of truth: https://www.flickr.com/serv - [Release](04-development/04-release.md) - [Method Registry Maintenance](04-development/05-method-registry.md) - [Repository Metadata](04-development/06-repository-metadata.md) +- [CI/CD](04-development/07-ci-cd.md) +- [AI Contributor Workflow](04-development/08-ai-contributor-workflow.md) +- [Secret Scanning](04-development/09-secret-scanning.md) ## Maintenance diff --git a/src/Cache/CacheKeyResolver.php b/src/Cache/CacheKeyResolver.php index da05fe0..5bbbad5 100644 --- a/src/Cache/CacheKeyResolver.php +++ b/src/Cache/CacheKeyResolver.php @@ -11,8 +11,36 @@ final class CacheKeyResolver */ public function resolve(string $method, array $parameters): string { - ksort($parameters); + $parameters = $this->sortParameters($parameters); return 'flickr:'.hash('xxh3', $method.serialize($parameters)); } + + /** + * @param array $parameters + * @return array + */ + private function sortParameters(array $parameters): array + { + ksort($parameters); + + foreach ($parameters as $key => $value) { + $parameters[$key] = $this->sortValue($value); + } + + return $parameters; + } + + private function sortValue(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + + if (array_is_list($value)) { + return array_map($this->sortValue(...), $value); + } + + return $this->sortParameters($value); + } } diff --git a/src/Client/FlickrClient.php b/src/Client/FlickrClient.php index 6beb5de..3c0a478 100644 --- a/src/Client/FlickrClient.php +++ b/src/Client/FlickrClient.php @@ -4,16 +4,22 @@ namespace JOOservices\Flickr\Client; +use JOOservices\Flickr\Cache\CacheKeyResolver; +use JOOservices\Flickr\Cache\NullCache; use JOOservices\Flickr\Config\FlickrConfig; use JOOservices\Flickr\Contracts\Auth\FlickrSignerContract; use JOOservices\Flickr\Contracts\Auth\FlickrTokenStoreContract; +use JOOservices\Flickr\Contracts\Cache\FlickrCacheContract; use JOOservices\Flickr\Contracts\Client\FlickrClientContract; use JOOservices\Flickr\Contracts\Client\FlickrTransportContract; use JOOservices\Flickr\DTO\Common\ApiResponseData; use JOOservices\Flickr\DTO\Common\RequestOptionsData; +use JOOservices\Flickr\Enums\CachePolicy; +use JOOservices\Flickr\Enums\HttpMethod; use JOOservices\Flickr\Enums\ResponseFormat; use JOOservices\Flickr\Exceptions\ApiException; use JOOservices\Flickr\Exceptions\AuthenticationException; +use JOOservices\Flickr\Metadata\FlickrMethodDefinition; use JOOservices\Flickr\Metadata\FlickrMethodRegistry; use JOOservices\Flickr\Support\ParameterNormalizer; @@ -27,12 +33,50 @@ public function __construct( private FlickrMethodRegistry $registry, private FlickrResponseParser $parser = new FlickrResponseParser, private ParameterNormalizer $normalizer = new ParameterNormalizer, + private FlickrCacheContract $cache = new NullCache, + private CacheKeyResolver $cacheKeys = new CacheKeyResolver, ) {} public function call(string $method, array $parameters = [], ?RequestOptionsData $options = null): ApiResponseData { $options ??= new RequestOptionsData; $definition = $this->registry->find($method); + $parameters = $this->prepareParameters($method, $parameters); + $cacheKey = $this->cacheKey($definition, $parameters, $options); + + if ($cacheKey !== null) { + $cached = $this->cache->get($cacheKey); + + if ($cached instanceof ApiResponseData) { + return $cached; + } + } + + $parameters = $this->authenticate($definition, $parameters, $options); + $raw = $this->transport->request( + $definition->httpMethod->value, + $this->config->restEndpoint, + $this->transportOptions($definition, $parameters), + ); + $response = $this->parser->parseApi($raw); + + if ($cacheKey !== null && $response->ok) { + $this->cache->put($cacheKey, $response, $options->cacheTtl ?? $this->config->publicCacheTtlSeconds); + } + + if (! $response->ok && $options->throwOnApiError) { + throw new ApiException($response->error->message); + } + + return $response; + } + + /** + * @param array $parameters + * @return array + */ + private function prepareParameters(string $method, array $parameters): array + { $parameters = $this->normalizer->normalize($parameters); $parameters['method'] = $method; $parameters['api_key'] = $this->config->apiKey; @@ -42,33 +86,66 @@ public function call(string $method, array $parameters = [], ?RequestOptionsData $parameters['nojsoncallback'] = 1; } - if ($definition->requiresAuth || $options->authenticated) { - $token = $this->tokens->get(); + return $parameters; + } - if ($token === null) { - throw new AuthenticationException("Flickr method {$method} requires an OAuth access token."); - } + /** + * @param array $parameters + * @return array + */ + private function authenticate(FlickrMethodDefinition $definition, array $parameters, RequestOptionsData $options): array + { + if (! $definition->requiresAuth && ! $options->authenticated) { + return $parameters; + } - $parameters = array_merge($parameters, $this->signer->sign( - $definition->httpMethod->value, - $this->config->restEndpoint, - $parameters, - $token->oauthToken, - $token->oauthTokenSecret, - )); + $token = $this->tokens->get(); + + if ($token === null) { + throw new AuthenticationException("Flickr method {$definition->name} requires an OAuth access token."); } - $transportOptions = $definition->httpMethod->value === 'POST' + return array_merge($parameters, $this->signer->sign( + $definition->httpMethod->value, + $this->config->restEndpoint, + $parameters, + $token->oauthToken, + $token->oauthTokenSecret, + )); + } + + /** + * @param array $parameters + * @return array> + */ + private function transportOptions(FlickrMethodDefinition $definition, array $parameters): array + { + return $definition->httpMethod === HttpMethod::Post ? ['form_params' => $parameters] : ['query' => $parameters]; + } - $raw = $this->transport->request($definition->httpMethod->value, $this->config->restEndpoint, $transportOptions); - $response = $this->parser->parseApi($raw); + /** + * @param array $parameters + */ + private function cacheKey(FlickrMethodDefinition $definition, array $parameters, RequestOptionsData $options): ?string + { + if (! $this->shouldUseCache($definition, $options)) { + return null; + } - if (! $response->ok && $options->throwOnApiError) { - throw new ApiException($response->error->message); + return $this->cacheKeys->resolve($definition->name, $parameters); + } + + private function shouldUseCache(FlickrMethodDefinition $definition, RequestOptionsData $options): bool + { + if ($options->cache === CachePolicy::Disabled) { + return false; } - return $response; + return $definition->cacheable + && $definition->httpMethod === HttpMethod::Get + && ! $definition->requiresAuth + && ! $options->authenticated; } } diff --git a/src/Client/FlickrUploadClient.php b/src/Client/FlickrUploadClient.php index c5fdb62..9befc4e 100644 --- a/src/Client/FlickrUploadClient.php +++ b/src/Client/FlickrUploadClient.php @@ -74,9 +74,15 @@ private function send(string $endpoint, string $path, array $parameters): Upload $token->oauthTokenSecret, )); - $raw = $this->transport->request('POST', $endpoint, [ - 'multipart' => $this->multipart->build($path, $parameters), - ]); + $multipart = $this->multipart->build($path, $parameters); + + try { + $raw = $this->transport->request('POST', $endpoint, [ + 'multipart' => $multipart, + ]); + } finally { + $this->multipart->close($multipart); + } return $this->parser->parseUpload($raw); } diff --git a/src/Client/MultipartRequestBuilder.php b/src/Client/MultipartRequestBuilder.php index fbbcbd0..5c0d091 100644 --- a/src/Client/MultipartRequestBuilder.php +++ b/src/Client/MultipartRequestBuilder.php @@ -26,4 +26,16 @@ public function build(string $path, array $parameters): array return $multipart; } + + /** + * @param list $multipart + */ + public function close(array $multipart): void + { + foreach ($multipart as $part) { + if (is_resource($part['contents'])) { + fclose($part['contents']); + } + } + } } diff --git a/src/Config/FlickrConfig.php b/src/Config/FlickrConfig.php index 2e6438e..f0388b8 100644 --- a/src/Config/FlickrConfig.php +++ b/src/Config/FlickrConfig.php @@ -24,6 +24,7 @@ public function __construct( public int $timeoutSeconds = 30, public int $retryTimes = 0, public string $userAgent = 'JOOservices Flickr SDK/1.0', + public int $publicCacheTtlSeconds = 300, ) { if (trim($this->apiKey) === '') { throw new ConfigurationException('Flickr API key is required.'); @@ -40,5 +41,9 @@ public function __construct( if ($this->retryTimes < 0) { throw new ConfigurationException('Retry times cannot be negative.'); } + + if ($this->publicCacheTtlSeconds < 1) { + throw new ConfigurationException('Public cache TTL must be at least 1 second.'); + } } } diff --git a/src/Contracts/Services/PhotoServiceContract.php b/src/Contracts/Services/PhotoServiceContract.php index a5a9012..0c5f6a0 100644 --- a/src/Contracts/Services/PhotoServiceContract.php +++ b/src/Contracts/Services/PhotoServiceContract.php @@ -5,6 +5,8 @@ namespace JOOservices\Flickr\Contracts\Services; use JOOservices\Flickr\DTO\Common\ApiResponseData; +use JOOservices\Flickr\DTO\Common\PaginationOptionsData; +use JOOservices\Flickr\DTO\Common\RequestOptionsData; use JOOservices\Flickr\DTO\Photos\SearchPhotosData; interface PhotoServiceContract @@ -96,6 +98,15 @@ public function removeTag(string $tagId): ApiResponseData; public function search(SearchPhotosData $data): ApiResponseData; + /** + * @return iterable + */ + public function searchPages( + SearchPhotosData $data, + ?PaginationOptionsData $pagination = null, + ?RequestOptionsData $requestOptions = null, + ): iterable; + /** * @param array $parameters */ diff --git a/src/DTO/Common/PaginationOptionsData.php b/src/DTO/Common/PaginationOptionsData.php new file mode 100644 index 0000000..fff5c46 --- /dev/null +++ b/src/DTO/Common/PaginationOptionsData.php @@ -0,0 +1,30 @@ +maxPages !== null && $this->maxPages < 1) { + throw new InvalidArgumentException('Max pages must be at least 1.'); + } + + if ($this->perPage !== null && $this->perPage < 1) { + throw new InvalidArgumentException('Per page must be at least 1.'); + } + + if ($this->startPage < 1) { + throw new InvalidArgumentException('Start page must be at least 1.'); + } + } +} diff --git a/src/FlickrFactory.php b/src/FlickrFactory.php index ca23771..0c7a524 100644 --- a/src/FlickrFactory.php +++ b/src/FlickrFactory.php @@ -12,6 +12,7 @@ use JOOservices\Flickr\Client\JooClientTransport; use JOOservices\Flickr\Config\FlickrConfig; use JOOservices\Flickr\Contracts\Auth\FlickrTokenStoreContract; +use JOOservices\Flickr\Contracts\Cache\FlickrCacheContract; use JOOservices\Flickr\Contracts\Client\FlickrTransportContract; use JOOservices\Flickr\Metadata\FlickrMethodRegistry; use JOOservices\Flickr\Services\ActivityService; @@ -63,12 +64,15 @@ public static function make( FlickrConfig $config, ?FlickrTokenStoreContract $tokenStore = null, ?FlickrTransportContract $transport = null, + ?FlickrCacheContract $cache = null, ): Flickr { $transport ??= JooClientTransport::fromConfig($config); $tokenStore ??= new InMemoryTokenStore; $registry = FlickrMethodRegistry::default(); $signer = new OAuth1Signer($config); - $client = new FlickrClient($config, $transport, $signer, $tokenStore, $registry); + $client = $cache === null + ? new FlickrClient($config, $transport, $signer, $tokenStore, $registry) + : new FlickrClient($config, $transport, $signer, $tokenStore, $registry, cache: $cache); $uploadClient = new FlickrUploadClient($config, $transport, $signer, $tokenStore); $raw = new RawApiService($client); diff --git a/src/Metadata/methods.php b/src/Metadata/methods.php index 5fc8b11..f49e276 100644 --- a/src/Metadata/methods.php +++ b/src/Metadata/methods.php @@ -11,12 +11,12 @@ return [ 'flickr.activity.userComments' => new FlickrMethodDefinition('flickr.activity.userComments', true, AuthPermission::Read, false, HttpMethod::Get, $docs('flickr.activity.userComments')), 'flickr.activity.userPhotos' => new FlickrMethodDefinition('flickr.activity.userPhotos', true, AuthPermission::Read, false, HttpMethod::Get, $docs('flickr.activity.userPhotos')), - 'flickr.auth.checkToken' => new FlickrMethodDefinition('flickr.auth.checkToken', false, null, true, HttpMethod::Get, $docs('flickr.auth.checkToken')), - 'flickr.auth.getFrob' => new FlickrMethodDefinition('flickr.auth.getFrob', false, null, true, HttpMethod::Get, $docs('flickr.auth.getFrob')), - 'flickr.auth.getFullToken' => new FlickrMethodDefinition('flickr.auth.getFullToken', false, null, true, HttpMethod::Get, $docs('flickr.auth.getFullToken')), - 'flickr.auth.getToken' => new FlickrMethodDefinition('flickr.auth.getToken', false, null, true, HttpMethod::Get, $docs('flickr.auth.getToken')), - 'flickr.auth.oauth.checkToken' => new FlickrMethodDefinition('flickr.auth.oauth.checkToken', false, null, true, HttpMethod::Get, $docs('flickr.auth.oauth.checkToken')), - 'flickr.auth.oauth.getAccessToken' => new FlickrMethodDefinition('flickr.auth.oauth.getAccessToken', false, null, true, HttpMethod::Get, $docs('flickr.auth.oauth.getAccessToken')), + 'flickr.auth.checkToken' => new FlickrMethodDefinition('flickr.auth.checkToken', false, null, false, HttpMethod::Get, $docs('flickr.auth.checkToken')), + 'flickr.auth.getFrob' => new FlickrMethodDefinition('flickr.auth.getFrob', false, null, false, HttpMethod::Get, $docs('flickr.auth.getFrob')), + 'flickr.auth.getFullToken' => new FlickrMethodDefinition('flickr.auth.getFullToken', false, null, false, HttpMethod::Get, $docs('flickr.auth.getFullToken')), + 'flickr.auth.getToken' => new FlickrMethodDefinition('flickr.auth.getToken', false, null, false, HttpMethod::Get, $docs('flickr.auth.getToken')), + 'flickr.auth.oauth.checkToken' => new FlickrMethodDefinition('flickr.auth.oauth.checkToken', false, null, false, HttpMethod::Get, $docs('flickr.auth.oauth.checkToken')), + 'flickr.auth.oauth.getAccessToken' => new FlickrMethodDefinition('flickr.auth.oauth.getAccessToken', false, null, false, HttpMethod::Get, $docs('flickr.auth.oauth.getAccessToken')), 'flickr.blogs.getList' => new FlickrMethodDefinition('flickr.blogs.getList', true, AuthPermission::Read, false, HttpMethod::Get, $docs('flickr.blogs.getList')), 'flickr.blogs.getServices' => new FlickrMethodDefinition('flickr.blogs.getServices', false, null, true, HttpMethod::Get, $docs('flickr.blogs.getServices')), 'flickr.blogs.postPhoto' => new FlickrMethodDefinition('flickr.blogs.postPhoto', true, AuthPermission::Write, false, HttpMethod::Post, $docs('flickr.blogs.postPhoto')), @@ -140,7 +140,7 @@ 'flickr.photos.suggestions.removeSuggestion' => new FlickrMethodDefinition('flickr.photos.suggestions.removeSuggestion', true, AuthPermission::Write, false, HttpMethod::Post, $docs('flickr.photos.suggestions.removeSuggestion')), 'flickr.photos.suggestions.suggestLocation' => new FlickrMethodDefinition('flickr.photos.suggestions.suggestLocation', true, AuthPermission::Write, false, HttpMethod::Post, $docs('flickr.photos.suggestions.suggestLocation')), 'flickr.photos.transform.rotate' => new FlickrMethodDefinition('flickr.photos.transform.rotate', true, AuthPermission::Write, false, HttpMethod::Post, $docs('flickr.photos.transform.rotate')), - 'flickr.photos.upload.checkTickets' => new FlickrMethodDefinition('flickr.photos.upload.checkTickets', false, null, true, HttpMethod::Get, $docs('flickr.photos.upload.checkTickets')), + 'flickr.photos.upload.checkTickets' => new FlickrMethodDefinition('flickr.photos.upload.checkTickets', false, null, false, HttpMethod::Get, $docs('flickr.photos.upload.checkTickets')), 'flickr.photosets.addPhoto' => new FlickrMethodDefinition('flickr.photosets.addPhoto', true, AuthPermission::Write, false, HttpMethod::Post, $docs('flickr.photosets.addPhoto')), 'flickr.photosets.comments.addComment' => new FlickrMethodDefinition('flickr.photosets.comments.addComment', true, AuthPermission::Write, false, HttpMethod::Post, $docs('flickr.photosets.comments.addComment')), 'flickr.photosets.comments.deleteComment' => new FlickrMethodDefinition('flickr.photosets.comments.deleteComment', true, AuthPermission::Write, false, HttpMethod::Post, $docs('flickr.photosets.comments.deleteComment')), diff --git a/src/Services/PhotoService.php b/src/Services/PhotoService.php index 3b98d2a..c7255b8 100644 --- a/src/Services/PhotoService.php +++ b/src/Services/PhotoService.php @@ -7,6 +7,8 @@ use InvalidArgumentException; use JOOservices\Flickr\Contracts\Services\PhotoServiceContract; use JOOservices\Flickr\DTO\Common\ApiResponseData; +use JOOservices\Flickr\DTO\Common\PaginationOptionsData; +use JOOservices\Flickr\DTO\Common\RequestOptionsData; use JOOservices\Flickr\DTO\Photos\SearchPhotosData; final class PhotoService extends AbstractRawService implements PhotoServiceContract @@ -158,17 +160,68 @@ public function removeTag(string $tagId): ApiResponseData public function search(SearchPhotosData $data): ApiResponseData { - return $this->callRaw('flickr.photos.search', array_merge($data->extraParameters, [ + return $this->callRaw('flickr.photos.search', $this->searchParameters($data)); + } + + /** + * @return iterable + */ + public function searchPages( + SearchPhotosData $data, + ?PaginationOptionsData $pagination = null, + ?RequestOptionsData $requestOptions = null, + ): iterable { + $pagination ??= new PaginationOptionsData; + $page = $pagination->startPage; + $pagesRead = 0; + + while ($pagination->maxPages === null || $pagesRead < $pagination->maxPages) { + $parameters = $this->searchParameters($data, $page, $pagination->perPage); + $response = $this->raw->call('flickr.photos.search', $parameters, $requestOptions); + + yield $response; + + $pagesRead++; + $items = $this->photoItems($response); + + if ($pagination->stopWhenEmpty && $items === []) { + break; + } + + if ($response->pagination === null || $page >= $response->pagination->pages) { + break; + } + + $page++; + } + } + + /** + * @return array + */ + private function searchParameters(SearchPhotosData $data, ?int $page = null, ?int $perPage = null): array + { + return array_merge($data->extraParameters, [ 'text' => $data->text, 'tags' => $data->tags, 'user_id' => $data->userId, 'extras' => $data->extras, - 'page' => $data->page, - 'per_page' => $data->perPage, + 'page' => $page ?? $data->page, + 'per_page' => $perPage ?? $data->perPage, 'sort' => $data->sort, 'tag_mode' => $data->tagMode, 'safe_search' => $data->safeSearch, - ])); + ]); + } + + /** + * @return list> + */ + private function photoItems(ApiResponseData $response): array + { + $photos = $response->data['photos']['photo'] ?? null; + + return is_array($photos) ? array_values($photos) : []; } /** diff --git a/tests/Fakes/ArrayCache.php b/tests/Fakes/ArrayCache.php new file mode 100644 index 0000000..bc9f786 --- /dev/null +++ b/tests/Fakes/ArrayCache.php @@ -0,0 +1,40 @@ + + */ + public array $items = []; + + public int $gets = 0; + + public int $puts = 0; + + public ?int $lastTtl = null; + + public function get(string $key): mixed + { + $this->gets++; + + return $this->items[$key] ?? null; + } + + public function put(string $key, mixed $value, ?int $ttl = null): void + { + $this->puts++; + $this->lastTtl = $ttl; + $this->items[$key] = $value; + } + + public function forget(string $key): void + { + unset($this->items[$key]); + } +} diff --git a/tests/Fakes/SpySigner.php b/tests/Fakes/SpySigner.php new file mode 100644 index 0000000..8581786 --- /dev/null +++ b/tests/Fakes/SpySigner.php @@ -0,0 +1,34 @@ +signCalls++; + + return [ + 'oauth_token' => (string) $token, + 'oauth_signature' => 'signed', + ]; + } + + public function signatureBaseString(string $method, string $url, array $parameters): string + { + return 'base'; + } +} diff --git a/tests/Fakes/SpyTokenStore.php b/tests/Fakes/SpyTokenStore.php new file mode 100644 index 0000000..24dad85 --- /dev/null +++ b/tests/Fakes/SpyTokenStore.php @@ -0,0 +1,32 @@ +getCalls++; + + return $this->token; + } + + public function put(AccessTokenData $token): void + { + $this->token = $token; + } + + public function forget(): void + { + $this->token = null; + } +} diff --git a/tests/Unit/ClientAndParserTest.php b/tests/Unit/ClientAndParserTest.php index 99f59ee..19904a7 100644 --- a/tests/Unit/ClientAndParserTest.php +++ b/tests/Unit/ClientAndParserTest.php @@ -4,24 +4,35 @@ namespace JOOservices\Flickr\Tests\Unit; +use GuzzleHttp\Psr7\Response; +use JOOservices\Client\Contracts\HttpClientInterface; +use JOOservices\Client\Contracts\ResponseWrapperInterface; use JOOservices\Flickr\Auth\InMemoryTokenStore; use JOOservices\Flickr\Auth\OAuth1Signer; use JOOservices\Flickr\Client\FlickrClient; use JOOservices\Flickr\Client\FlickrResponseParser; use JOOservices\Flickr\Client\FlickrUploadClient; +use JOOservices\Flickr\Client\JooClientTransport; use JOOservices\Flickr\Config\FlickrConfig; use JOOservices\Flickr\DTO\Auth\AccessTokenData; use JOOservices\Flickr\DTO\Common\RawResponseData; use JOOservices\Flickr\DTO\Common\RequestOptionsData; use JOOservices\Flickr\DTO\Upload\ReplacePhotoData; use JOOservices\Flickr\DTO\Upload\UploadPhotoData; +use JOOservices\Flickr\Enums\CachePolicy; use JOOservices\Flickr\Enums\Privacy; use JOOservices\Flickr\Exceptions\AuthenticationException; use JOOservices\Flickr\Exceptions\InvalidResponseException; +use JOOservices\Flickr\Exceptions\TransportException; use JOOservices\Flickr\Exceptions\UploadException; use JOOservices\Flickr\Metadata\FlickrMethodRegistry; +use JOOservices\Flickr\Tests\Fakes\ArrayCache; use JOOservices\Flickr\Tests\Fakes\FakeTransport; +use JOOservices\Flickr\Tests\Fakes\SpySigner; +use JOOservices\Flickr\Tests\Fakes\SpyTokenStore; use JOOservices\Flickr\Tests\TestCase; +use Psr\Http\Message\ResponseInterface; +use RuntimeException; final class ClientAndParserTest extends TestCase { @@ -66,6 +77,68 @@ public function test_raw_client_signs_authenticated_mutation_and_uses_post(): vo $this->assertArrayHasKey('oauth_signature', $request['options']['form_params']); } + public function test_public_cacheable_get_uses_cache_and_stable_parameter_keys(): void + { + $transport = new FakeTransport([ + new RawResponseData(200, '{"stat":"ok","photos":{"page":1,"pages":1,"perpage":10,"total":"1","photo":[{"id":"1"}]}}'), + ]); + $cache = new ArrayCache; + $client = $this->client($transport, cache: $cache); + + $first = $client->call('flickr.photos.search', ['text' => 'cats', 'per_page' => 10]); + $second = $client->call('flickr.photos.search', ['per_page' => 10, 'text' => 'cats']); + + $this->assertTrue($first->ok); + $this->assertSame($first, $second); + $this->assertCount(1, $transport->requests); + $this->assertSame(1, $cache->puts); + $this->assertSame(300, $cache->lastTtl); + } + + public function test_cache_bypasses_authenticated_auth_required_post_failed_and_disabled_requests(): void + { + $transport = new FakeTransport([ + new RawResponseData(200, '{"stat":"ok"}'), + new RawResponseData(200, '{"stat":"ok"}'), + new RawResponseData(200, '{"stat":"ok"}'), + new RawResponseData(200, '{"stat":"fail","code":1,"message":"Nope"}'), + new RawResponseData(200, '{"stat":"ok"}'), + ]); + $cache = new ArrayCache; + $client = $this->client($transport, new InMemoryTokenStore(new AccessTokenData('token', 'token-secret')), $cache); + + $client->call('flickr.photos.search', [], new RequestOptionsData(authenticated: true)); + $client->call('flickr.photos.getContactsPhotos'); + $client->call('flickr.photos.delete', ['photo_id' => '1']); + $client->call('flickr.photos.search', ['text' => 'failed']); + $client->call('flickr.photos.search', ['text' => 'disabled'], new RequestOptionsData(cache: CachePolicy::Disabled)); + + $this->assertCount(5, $transport->requests); + $this->assertSame(0, $cache->puts); + } + + public function test_request_signing_guardrails_avoid_token_and_signer_work_for_public_gets(): void + { + $transport = new FakeTransport([ + new RawResponseData(200, '{"stat":"ok"}'), + new RawResponseData(200, '{"stat":"ok"}'), + ]); + $signer = new SpySigner; + $tokens = new SpyTokenStore(new AccessTokenData('token', 'token-secret')); + $config = new FlickrConfig('key', 'secret'); + $client = new FlickrClient($config, $transport, $signer, $tokens, FlickrMethodRegistry::default()); + + $client->call('flickr.photos.search'); + + $this->assertSame(0, $tokens->getCalls); + $this->assertSame(0, $signer->signCalls); + + $client->call('flickr.photos.delete', ['photo_id' => '1']); + + $this->assertSame(1, $tokens->getCalls); + $this->assertSame(1, $signer->signCalls); + } + public function test_parser_maps_failure_and_rejects_malformed_responses(): void { $parser = new FlickrResponseParser; @@ -78,6 +151,47 @@ public function test_parser_maps_failure_and_rejects_malformed_responses(): void $parser->parseApi(new RawResponseData(200, '{"ok"')); } + public function test_parser_rejects_empty_scalar_and_missing_stat_api_responses(): void + { + $parser = new FlickrResponseParser; + + foreach (['', 'true', '{"photos":[]}'] as $body) { + try { + $parser->parseApi(new RawResponseData(200, $body)); + $this->fail("Expected invalid response for body [{$body}]."); + } catch (InvalidResponseException) { + $this->addToAssertionCount(1); + } + } + } + + public function test_parser_maps_xml_api_success_failure_and_invalid_xml(): void + { + $parser = new FlickrResponseParser; + + $success = $parser->parseApi(new RawResponseData(200, '')); + $failure = $parser->parseApi(new RawResponseData(200, '')); + + $this->assertTrue($success->ok); + $this->assertSame(['@attributes' => ['stat' => 'ok'], 'photos' => ['@attributes' => ['page' => '1']]], $success->data); + $this->assertFalse($failure->ok); + $this->assertSame(100, $failure->error?->code); + $this->assertSame('Invalid', $failure->error?->message); + + foreach (['', ''] as $body) { + $previous = libxml_use_internal_errors(true); + try { + $parser->parseApi(new RawResponseData(200, $body)); + $this->fail("Expected invalid XML API response for body [{$body}]."); + } catch (InvalidResponseException) { + $this->addToAssertionCount(1); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + } + public function test_parser_maps_xml_upload_photo_and_ticket_responses(): void { $parser = new FlickrResponseParser; @@ -89,6 +203,32 @@ public function test_parser_maps_xml_upload_photo_and_ticket_responses(): void $this->assertSame('999', $async->ticketId); } + public function test_parser_maps_root_upload_responses_and_rejects_upload_errors(): void + { + $parser = new FlickrResponseParser; + + $photo = $parser->parseUpload(new RawResponseData(200, '123')); + $ticket = $parser->parseUpload(new RawResponseData(200, '999')); + + $this->assertSame('123', $photo->photoId); + $this->assertSame('s', $photo->secret); + $this->assertSame('o', $photo->originalSecret); + $this->assertSame('999', $ticket->ticketId); + + foreach (['', '', '', 'parseUpload(new RawResponseData(200, $body)); + $this->fail("Expected invalid upload response for body [{$body}]."); + } catch (InvalidResponseException) { + $this->addToAssertionCount(1); + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + } + public function test_upload_client_validates_file_and_builds_signed_multipart_request(): void { $path = tempnam(sys_get_temp_dir(), 'flickr-upload-'); @@ -114,11 +254,13 @@ public function test_upload_client_validates_file_and_builds_signed_multipart_re $multipart = $transport->lastRequest()['options']['multipart']; $names = array_column($multipart, 'name'); + $photo = $multipart[array_search('photo', $names, true)]; $this->assertSame('999', $result->ticketId); $this->assertContains('photo', $names); $this->assertContains('oauth_signature', $names); $this->assertContains('is_friend', $names); + $this->assertFalse(is_resource($photo['contents'])); } public function test_upload_client_rejects_missing_file_and_missing_token(): void @@ -134,7 +276,86 @@ public function test_upload_client_rejects_missing_file_and_missing_token(): voi $client->replace(new ReplacePhotoData('/missing/photo.jpg', '1')); } - private function client(FakeTransport $transport, ?InMemoryTokenStore $tokens = null): FlickrClient + public function test_joo_client_transport_maps_psr_responses_and_wraps_failures(): void + { + $client = new class implements HttpClientInterface + { + public bool $fail = false; + + public function get(string $uri, array $options = []): ResponseWrapperInterface + { + return $this->request('GET', $uri, $options); + } + + public function post(string $uri, array $options = []): ResponseWrapperInterface + { + return $this->request('POST', $uri, $options); + } + + public function put(string $uri, array $options = []): ResponseWrapperInterface + { + return $this->request('PUT', $uri, $options); + } + + public function patch(string $uri, array $options = []): ResponseWrapperInterface + { + return $this->request('PATCH', $uri, $options); + } + + public function delete(string $uri, array $options = []): ResponseWrapperInterface + { + return $this->request('DELETE', $uri, $options); + } + + public function request(string $method, string $uri, array $options = []): ResponseWrapperInterface + { + if ($this->fail) { + throw new RuntimeException('Network failed.'); + } + + return new class implements ResponseWrapperInterface + { + public function status(): int + { + return 201; + } + + public function header(string $name): ?string + { + return $name === 'X-Test' ? 'yes' : null; + } + + public function json(): array + { + return ['stat' => 'ok']; + } + + public function toPsrResponse(): ResponseInterface + { + return new Response(201, ['X-Test' => 'yes'], 'body'); + } + + public function toDto(string $dtoClass): object + { + return new $dtoClass; + } + }; + } + }; + $transport = new JooClientTransport($client); + + $response = $transport->request('GET', 'https://example.test', ['query' => ['a' => 'b']]); + + $this->assertSame(201, $response->statusCode); + $this->assertSame('body', $response->body); + $this->assertSame(['yes'], $response->headers['X-Test']); + + $client->fail = true; + $this->expectException(TransportException::class); + $transport->request('GET', 'https://example.test'); + } + + private function client(FakeTransport $transport, ?InMemoryTokenStore $tokens = null, ?ArrayCache $cache = null): FlickrClient { $config = new FlickrConfig('key', 'secret'); @@ -144,6 +365,7 @@ private function client(FakeTransport $transport, ?InMemoryTokenStore $tokens = new OAuth1Signer($config), $tokens ?? new InMemoryTokenStore, FlickrMethodRegistry::default(), + cache: $cache ?? new ArrayCache, ); } } diff --git a/tests/Unit/ConfigAndDtoTest.php b/tests/Unit/ConfigAndDtoTest.php index d2fdcb2..9870fee 100644 --- a/tests/Unit/ConfigAndDtoTest.php +++ b/tests/Unit/ConfigAndDtoTest.php @@ -6,9 +6,34 @@ use JOOservices\Flickr\Client\FakeFlickrTransport; use JOOservices\Flickr\Config\FlickrConfig; +use JOOservices\Flickr\DTO\Auth\AuthorizationUrlData; +use JOOservices\Flickr\DTO\Auth\AuthorizedUserData; +use JOOservices\Flickr\DTO\Auth\OAuthConsumerData; +use JOOservices\Flickr\DTO\Auth\RequestTokenData; +use JOOservices\Flickr\DTO\Common\PaginationOptionsData; +use JOOservices\Flickr\DTO\Favorites\FavoritePhotoData; +use JOOservices\Flickr\DTO\Galleries\GalleryData; +use JOOservices\Flickr\DTO\Galleries\GalleryPhotoData; +use JOOservices\Flickr\DTO\Groups\GroupData; +use JOOservices\Flickr\DTO\Groups\GroupPoolData; +use JOOservices\Flickr\DTO\People\PersonData; +use JOOservices\Flickr\DTO\People\UploadStatusData; +use JOOservices\Flickr\DTO\Photos\PhotoData; +use JOOservices\Flickr\DTO\Photos\PhotoExifData; +use JOOservices\Flickr\DTO\Photos\PhotoInfoData; +use JOOservices\Flickr\DTO\Photos\PhotoMetadataData; +use JOOservices\Flickr\DTO\Photos\PhotoPermissionData; +use JOOservices\Flickr\DTO\Photos\PhotoSizeData; use JOOservices\Flickr\DTO\Photos\SearchPhotosData; +use JOOservices\Flickr\DTO\Photosets\CreatePhotosetData; +use JOOservices\Flickr\DTO\Photosets\PhotosetData; +use JOOservices\Flickr\DTO\Photosets\PhotosetPhotoData; +use JOOservices\Flickr\DTO\Tags\MachineTagData; +use JOOservices\Flickr\DTO\Tags\TagData; use JOOservices\Flickr\DTO\Upload\ReplacePhotoData; use JOOservices\Flickr\DTO\Upload\UploadPhotoData; +use JOOservices\Flickr\DTO\Upload\UploadTicketData; +use JOOservices\Flickr\Enums\AuthPermission; use JOOservices\Flickr\Enums\ContentType; use JOOservices\Flickr\Enums\HiddenStatus; use JOOservices\Flickr\Enums\HttpMethod; @@ -66,4 +91,35 @@ public function test_package_autoloads_factory_with_fake_transport_and_has_no_la $this->assertFalse(class_exists('JOOservices\\Flickr\\FlickrServiceProvider')); $this->assertFalse(class_exists('JOOservices\\Flickr\\Facades\\Flickr')); } + + public function test_public_dto_shapes_are_constructible(): void + { + $dtos = [ + new AuthorizationUrlData(new RequestTokenData('request-token', 'request-secret'), AuthPermission::Read), + new AuthorizedUserData('1', 'username', 'Full Name'), + new OAuthConsumerData('key', 'secret', 'https://example.test/callback'), + new PaginationOptionsData(startPage: 2, perPage: 10, maxPages: 3, stopWhenEmpty: false), + new FavoritePhotoData('1', 'owner'), + new GalleryData('gallery-id', 'Gallery'), + new GalleryPhotoData('1', 'Title'), + new GroupData('group-id', 'Group'), + new GroupPoolData(['id' => '1', 'title' => 'Title']), + new PersonData('nsid', ['username' => 'username']), + new UploadStatusData(['is_pro' => true, 'used' => 10, 'max' => 20]), + new PhotoData('1', 'Title', 'owner'), + new PhotoExifData(['make' => 'Make', 'model' => 'Model']), + new PhotoInfoData('1', ['title' => 'Title']), + new PhotoMetadataData(title: 'Title'), + new PhotoPermissionData(isPublic: true, isFriend: false, isFamily: false), + new PhotoSizeData('Large', 'https://example.test/photo.jpg', 640, 480), + new CreatePhotosetData('Set', '1'), + new PhotosetData('set-id', 'Title'), + new PhotosetPhotoData('1', 'Title'), + new MachineTagData('namespace', 'predicate', 'value'), + new TagData('tag'), + new UploadTicketData('ticket-id', complete: false), + ]; + + $this->assertCount(23, $dtos); + } } diff --git a/tests/Unit/OAuthTest.php b/tests/Unit/OAuthTest.php index 5338afd..eb5f00c 100644 --- a/tests/Unit/OAuthTest.php +++ b/tests/Unit/OAuthTest.php @@ -6,6 +6,7 @@ use JOOservices\Flickr\Auth\FileTokenStore; use JOOservices\Flickr\Auth\InMemoryTokenStore; +use JOOservices\Flickr\Auth\NullTokenStore; use JOOservices\Flickr\Auth\OAuth1Authenticator; use JOOservices\Flickr\Auth\OAuth1Signer; use JOOservices\Flickr\Config\FlickrConfig; @@ -98,6 +99,51 @@ public function test_file_token_store_rejects_corrupted_json(): void } } + public function test_file_token_store_round_trips_and_forgets_tokens(): void + { + $path = sys_get_temp_dir().'/flickr-token-round-trip-'.bin2hex(random_bytes(4)).'.json'; + $store = new FileTokenStore($path); + $token = new AccessTokenData('token', 'secret', 'user', 'username'); + + try { + $store->put($token); + + $this->assertFileExists($path); + $this->assertEquals($token, $store->get()); + + $store->forget(); + $this->assertFileDoesNotExist($path); + $store->forget(); + $this->assertFileDoesNotExist($path); + } finally { + @unlink($path); + } + } + + public function test_file_token_store_rejects_non_object_json(): void + { + $path = sys_get_temp_dir().'/flickr-token-scalar-'.bin2hex(random_bytes(4)).'.json'; + file_put_contents($path, '"token"'); + + $this->expectException(TokenStorageException::class); + + try { + (new FileTokenStore($path))->get(); + } finally { + @unlink($path); + } + } + + public function test_null_token_store_discards_tokens(): void + { + $store = new NullTokenStore; + + $this->assertNull($store->get()); + $store->put(new AccessTokenData('token', 'secret')); + $store->forget(); + $this->assertNull($store->get()); + } + public function test_file_token_store_handles_missing_and_empty_files_without_leaking_secrets(): void { $missing = sys_get_temp_dir().'/flickr-token-missing-'.bin2hex(random_bytes(4)).'.json'; diff --git a/tests/Unit/OfficialMethodCoverageTest.php b/tests/Unit/OfficialMethodCoverageTest.php index 9ba20a0..f9f7d63 100644 --- a/tests/Unit/OfficialMethodCoverageTest.php +++ b/tests/Unit/OfficialMethodCoverageTest.php @@ -6,10 +6,15 @@ use JOOservices\Flickr\Config\FlickrConfig; use JOOservices\Flickr\DTO\Auth\AccessTokenData; +use JOOservices\Flickr\DTO\Photos\SearchPhotosData; +use JOOservices\Flickr\DTO\Photosets\CreatePhotosetData; +use JOOservices\Flickr\Enums\HttpMethod; use JOOservices\Flickr\FlickrFactory; use JOOservices\Flickr\Metadata\FlickrMethodDefinition; +use JOOservices\Flickr\Metadata\FlickrMethodRegistry; use JOOservices\Flickr\Tests\Fakes\FakeTransport; use JOOservices\Flickr\Tests\TestCase; +use ReflectionNamedType; final class OfficialMethodCoverageTest extends TestCase { @@ -38,6 +43,36 @@ public function test_registry_verification_tool_passes_against_local_fixture(): $this->assertSame('Verified 224 official Flickr REST method definitions.', $output[0] ?? ''); } + public function test_sensitive_auth_upload_and_mutation_methods_are_not_cacheable(): void + { + $registered = require __DIR__.'/../../src/Metadata/methods.php'; + + foreach ($registered as $method => $definition) { + if (str_starts_with($method, 'flickr.auth.')) { + $this->assertFalse($definition->cacheable, "{$method} must not be cacheable."); + } + + if ($method === 'flickr.photos.upload.checkTickets') { + $this->assertFalse($definition->cacheable, "{$method} must not be cacheable."); + } + + if ($definition->httpMethod === HttpMethod::Post) { + $this->assertFalse($definition->cacheable, "{$method} mutations must not be cacheable."); + } + + if ($definition->authPermission !== null) { + $this->assertFalse($definition->cacheable, "{$method} permissioned calls must not be cacheable."); + } + + if ($definition->requiresAuth) { + $this->assertFalse($definition->cacheable, "{$method} authenticated calls must not be cacheable by default."); + } + } + + $unknown = FlickrMethodRegistry::default()->find('flickr.future.method'); + $this->assertFalse($unknown->cacheable); + } + public function test_root_services_expose_wrapper_methods_for_every_official_method(): void { $flickr = FlickrFactory::make( @@ -55,6 +90,31 @@ public function test_root_services_expose_wrapper_methods_for_every_official_met } } + public function test_every_official_wrapper_dispatches_to_expected_raw_method(): void + { + $transport = new FakeTransport; + $flickr = FlickrFactory::make( + new FlickrConfig('key', 'secret'), + transport: $transport, + ); + $flickr->tokens()->put(new AccessTokenData('token', 'token-secret')); + + foreach ($this->officialMethods() as $index => $method) { + $accessor = $this->accessor($this->categoryOf($method)); + $wrapper = $this->wrapperMethod($method); + $service = $flickr->{$accessor}(); + + $service->{$wrapper}(...$this->argumentsFor($service, $wrapper)); + + $request = $transport->requests[$index]; + $parameters = $request['options']['query'] ?? $request['options']['form_params'] ?? []; + + $this->assertSame($method, $parameters['method'] ?? null, "{$accessor}()->{$wrapper}() dispatched the wrong raw method."); + } + + $this->assertCount(count($this->officialMethods()), $transport->requests); + } + public function test_all_apis_example_catalog_covers_every_official_method(): void { require_once __DIR__.'/../../examples/all-apis.php'; @@ -126,4 +186,37 @@ private function wrapperMethod(string $method): string return end($parts); } + + /** + * @return list + */ + private function argumentsFor(object $service, string $method): array + { + $reflection = new \ReflectionMethod($service, $method); + $arguments = []; + + foreach ($reflection->getParameters() as $parameter) { + if ($parameter->isOptional()) { + continue; + } + + $type = $parameter->getType(); + $typeName = $type instanceof ReflectionNamedType ? $type->getName() : null; + + $arguments[] = match ($typeName) { + 'array' => $parameter->getName() === 'tags' ? ['tag-one', 'tag-two'] : ['sample' => 'value'], + 'string' => str_contains(strtolower($parameter->getName()), 'title') ? 'Example title' : '123', + SearchPhotosData::class => new SearchPhotosData(text: 'sunset'), + CreatePhotosetData::class => new CreatePhotosetData('Example set', '123', 'Example description'), + default => throw new \LogicException(sprintf( + 'No test argument is defined for %s::%s parameter $%s.', + $reflection->getDeclaringClass()->getName(), + $method, + $parameter->getName(), + )), + }; + } + + return $arguments; + } } diff --git a/tests/Unit/ServiceTest.php b/tests/Unit/ServiceTest.php index 92b75ae..e0194d3 100644 --- a/tests/Unit/ServiceTest.php +++ b/tests/Unit/ServiceTest.php @@ -5,6 +5,11 @@ namespace JOOservices\Flickr\Tests\Unit; use JOOservices\Flickr\Contracts\Client\FlickrUploadClientContract; +use JOOservices\Flickr\Contracts\Services\RawApiServiceContract; +use JOOservices\Flickr\DTO\Common\ApiResponseData; +use JOOservices\Flickr\DTO\Common\PaginationData; +use JOOservices\Flickr\DTO\Common\PaginationOptionsData; +use JOOservices\Flickr\DTO\Common\RequestOptionsData; use JOOservices\Flickr\DTO\Photos\SearchPhotosData; use JOOservices\Flickr\DTO\Photosets\CreatePhotosetData; use JOOservices\Flickr\DTO\Upload\ReplacePhotoData; @@ -64,6 +69,81 @@ public function replace(ReplacePhotoData $data): UploadResultData $this->assertSame(['1', '2'], $raw->lastCall()['parameters']['tickets']); } + public function test_photo_search_pages_yields_lazily_and_stops_at_total_pages(): void + { + $raw = new class implements RawApiServiceContract + { + /** + * @var list, options: ?RequestOptionsData}> + */ + public array $calls = []; + + public function call(string $method, array $parameters = [], ?RequestOptionsData $options = null): ApiResponseData + { + $this->calls[] = compact('method', 'parameters', 'options'); + $page = (int) $parameters['page']; + + return new ApiResponseData( + ok: true, + data: ['photos' => ['photo' => [['id' => (string) $page]]]], + pagination: new PaginationData($page, 2, (int) $parameters['per_page'], 2), + ); + } + }; + $photos = new PhotoService($raw); + + $pages = $photos->searchPages( + SearchPhotosData::from(['text' => 'sunset', 'perPage' => 50]), + new PaginationOptionsData(perPage: 25), + new RequestOptionsData(cacheTtl: 60), + ); + + $this->assertCount(0, $raw->calls); + $collected = iterator_to_array($pages); + + $this->assertCount(2, $collected); + $this->assertSame(1, $raw->calls[0]['parameters']['page']); + $this->assertSame(25, $raw->calls[0]['parameters']['per_page']); + $this->assertSame(2, $raw->calls[1]['parameters']['page']); + $this->assertSame(60, $raw->calls[0]['options']?->cacheTtl); + } + + public function test_photo_search_pages_respects_max_pages_and_empty_stop(): void + { + $raw = new class implements RawApiServiceContract + { + /** + * @var list, options: ?RequestOptionsData}> + */ + public array $calls = []; + + public function call(string $method, array $parameters = [], ?RequestOptionsData $options = null): ApiResponseData + { + $this->calls[] = compact('method', 'parameters', 'options'); + + return new ApiResponseData( + ok: true, + data: ['photos' => ['photo' => []]], + pagination: new PaginationData((int) $parameters['page'], 5, (int) $parameters['per_page'], 0), + ); + } + }; + $photos = new PhotoService($raw); + + $emptyStop = iterator_to_array($photos->searchPages( + new SearchPhotosData, + new PaginationOptionsData(maxPages: 3), + )); + $this->assertCount(1, $emptyStop); + + $continueEmpty = iterator_to_array($photos->searchPages( + new SearchPhotosData, + new PaginationOptionsData(maxPages: 2, stopWhenEmpty: false), + )); + $this->assertCount(2, $continueEmpty); + $this->assertCount(3, $raw->calls); + } + public function test_services_reject_invalid_inputs(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/Unit/SupportAndRegistryTest.php b/tests/Unit/SupportAndRegistryTest.php index b48b5ba..2271d91 100644 --- a/tests/Unit/SupportAndRegistryTest.php +++ b/tests/Unit/SupportAndRegistryTest.php @@ -4,12 +4,21 @@ namespace JOOservices\Flickr\Tests\Unit; +use DateInterval; +use JOOservices\Flickr\Cache\NullCache; +use JOOservices\Flickr\Cache\Psr16Cache; use JOOservices\Flickr\Enums\AuthPermission; use JOOservices\Flickr\Enums\HttpMethod; use JOOservices\Flickr\Enums\Privacy; +use JOOservices\Flickr\Exceptions\UploadException; use JOOservices\Flickr\Metadata\FlickrMethodRegistry; +use JOOservices\Flickr\Support\FileValidator; use JOOservices\Flickr\Support\ParameterNormalizer; +use JOOservices\Flickr\Support\QueryString; +use JOOservices\Flickr\Support\SignatureBaseStringBuilder; +use JOOservices\Flickr\Support\UrlBuilder; use JOOservices\Flickr\Tests\TestCase; +use Psr\SimpleCache\CacheInterface; final class SupportAndRegistryTest extends TestCase { @@ -40,4 +49,145 @@ public function test_method_registry_known_and_unknown_fallback(): void $this->assertFalse($unknown->requiresAuth); $this->assertSame(HttpMethod::Get, $unknown->httpMethod); } + + public function test_query_strings_and_signature_base_strings_handle_repeated_values_and_ports(): void + { + $this->assertSame('flag=1&tags=php&tags=sdk', QueryString::build([ + 'flag' => true, + 'tags' => ['php', 'sdk'], + ])); + + $builder = new SignatureBaseStringBuilder; + $base = $builder->build('GET', 'https://Example.test:8443/rest?ignored=1', [ + 'b' => 'two', + 'a' => ['z', 'a'], + 'oauth_signature' => 'ignored', + 'empty' => null, + ]); + + $this->assertStringStartsWith('GET&https%3A%2F%2Fexample.test%3A8443%2Frest&', $base); + $this->assertStringContainsString('a%3Da%26a%3Dz%26b%3Dtwo', $base); + + $defaultPort = $builder->build('GET', 'https://Example.test:443/rest', ['a' => 'b']); + $this->assertStringContainsString('https%3A%2F%2Fexample.test%2Frest', $defaultPort); + } + + public function test_file_validator_accepts_readable_files_and_rejects_bad_paths(): void + { + $validator = new FileValidator; + $file = tempnam(sys_get_temp_dir(), 'flickr-readable-'); + file_put_contents($file, 'bytes'); + + try { + $validator->validateReadableFile($file); + $this->addToAssertionCount(1); + } finally { + @unlink($file); + } + + foreach (['', sys_get_temp_dir(), tempnam(sys_get_temp_dir(), 'flickr-empty-')] as $path) { + try { + $validator->validateReadableFile($path); + $this->fail("Expected invalid file path [{$path}]."); + } catch (UploadException) { + $this->addToAssertionCount(1); + } finally { + if (is_file($path)) { + @unlink($path); + } + } + } + } + + public function test_url_builder_normalizes_paths(): void + { + $builder = new UrlBuilder; + + $this->assertSame('https://example.test/rest?method=flickr.test.echo', $builder->withQuery('https://example.test/rest', [ + 'method' => 'flickr.test.echo', + ])); + $this->assertSame('https://example.test/rest?format=json&method=flickr.test.echo', $builder->withQuery('https://example.test/rest?format=json', [ + 'method' => 'flickr.test.echo', + ])); + $this->assertSame('https://example.test/rest', $builder->withQuery('https://example.test/rest', [])); + } + + public function test_cache_adapters_delegate_and_discard_values(): void + { + $psr = new class implements CacheInterface + { + /** + * @var array + */ + public array $items = []; + + public function get(string $key, mixed $default = null): mixed + { + return $this->items[$key] ?? $default; + } + + public function set(string $key, mixed $value, DateInterval|int|null $ttl = null): bool + { + $this->items[$key] = [$value, $ttl]; + + return true; + } + + public function delete(string $key): bool + { + unset($this->items[$key]); + + return true; + } + + public function clear(): bool + { + $this->items = []; + + return true; + } + + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + foreach ($keys as $key) { + yield $key => $this->get((string) $key, $default); + } + } + + public function setMultiple(iterable $values, DateInterval|int|null $ttl = null): bool + { + foreach ($values as $key => $value) { + $this->set((string) $key, $value, $ttl); + } + + return true; + } + + public function deleteMultiple(iterable $keys): bool + { + foreach ($keys as $key) { + $this->delete((string) $key); + } + + return true; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->items); + } + }; + $cache = new Psr16Cache($psr); + + $cache->put('key', 'value', 60); + $this->assertSame(['value', 60], $cache->get('key')); + $cache->forget('key'); + $this->assertNull($cache->get('key')); + + $null = new NullCache; + $null->put('key', 'value'); + $this->assertNull($null->get('key')); + $null->forget('key'); + $this->assertNull($null->get('key')); + } }