diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b03e950..b578faf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ name: CI -on: +on: push: branches: - main @@ -10,17 +10,22 @@ on: - main - "*.x" +permissions: + contents: read + jobs: tests: name: PHP ${{ matrix.php }} Test runs-on: ubuntu-latest + strategy: + fail-fast: false matrix: php: ['8.1', '8.2', '8.3', '8.4', '8.5'] steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -29,6 +34,21 @@ jobs: tools: composer:v2 coverage: none + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache Composer dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('composer.json') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}-composer- + + - name: Validate Composer configuration + run: composer validate --strict + - name: Install dependencies run: composer update --prefer-dist --no-interaction --no-progress diff --git a/.gitignore b/.gitignore index 82321d0..7fd3a28 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ /logs/ /.idea /index.php -/src/TestApi.php \ No newline at end of file +/src/TestApi.php +/AGENTS.md diff --git a/README.md b/README.md index a6cf77b..881a76e 100644 --- a/README.md +++ b/README.md @@ -4,833 +4,48 @@ [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) [![Tests](https://github.com/programmatordev/php-api-sdk/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/programmatordev/php-api-sdk/actions/workflows/ci.yml?query=branch%3Amain) -A library for creating SDKs in PHP with support for: -- [PSR-18 HTTP clients](https://www.php-fig.org/psr/psr-18); -- [PSR-17 HTTP factories](https://www.php-fig.org/psr/psr-17); -- [PSR-6 caches](https://www.php-fig.org/psr/psr-6); -- [PSR-3 logs](https://www.php-fig.org/psr/psr-3); -- Authentication; -- Event listeners; -- ...and more. +These docs describe how to create API SDKs with this package. -All methods are public for full hackability 🔥. +This package is built for two developer audiences: -## Requirements - -- PHP 8.1 or higher. - -## Installation - -Install the library via [Composer](https://getcomposer.org/): - -```bash -composer require programmatordev/php-api-sdk -``` - -## Basic Usage - -Just extend your API library with the `Api` class and have fun coding: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct() - { - parent::__construct(); - - // recommended config - $this->setBaseUrl('https://api.example.com/v1'); - } - - public function getPosts(int $page = 1): string - { - // GET https://api.example.com/v1/posts?page=1 - return $this->request( - method: 'GET', - path: '/posts', - query: [ - 'page' => $page - ] - ); - } -} -``` - -## Documentation - -- [Base URL](#base-url) -- [Requests](#requests) -- [Query defaults](#query-defaults) -- [Header defaults](#header-defaults) -- [Authentication](#authentication) -- [Event listeners](#event-listeners) -- [HTTP client (PSR-18) and HTTP factories (PSR-17)](#http-client-psr-18-and-http-factories-psr-17) -- [Cache (PSR-6)](#cache-psr-6) -- [Logger (PSR-3)](#logger-psr-3) - -### Base URL - -Getter and setter for the base URL. -Base URL is the common part of the API URL and will be used in all requests. - -```php -$this->setBaseUrl(?string $baseUrl): self -``` - -```php -$this->getBaseUrl(): ?string -``` - -### Requests - -- [`request`](#request) -- [`buildPath`](#buildpath) - -#### `request` - -This method is used to send a request to an API. - -```php -use Psr\Http\Message\StreamInterface; - -$this->request( - string $method, - string $path, - array $query = [], - array $headers = [], - StreamInterface|string $body = null -): mixed -``` - -> [!NOTE] -> A `ClientException` will be thrown if there is an error while processing the request. - -For example, if you wanted to get a list of users with pagination: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct() - { - parent::__construct(); - - // recommended config - $this->setBaseUrl('https://api.example.com/v1'); - } - - public function getUsers(int $page = 1, int $perPage = 20): string - { - // GET https://api.example.com/v1/users?page=1&limit=20 - return $this->request( - method: 'GET', - path: '/users', - query: [ - 'page' => $page, - 'limit' => $perPage - ] - ); - } -} -``` - -By default, this method will return a `string` as it will be the response of the request as is. -If you want to change how the response is handled in all requests (for example, decode a JSON string into an array), -check the [`addResponseContentsListener`](#addresponsecontentslistener) method in the [Event Listeners](#event-listeners) section. - -> [!NOTE] -> If the `path` set is a full URL, it will be used as the request URL even if a `baseUrl` is set. - -#### `buildPath` - -The purpose of this method is to have an easy way to build a properly formatted path depending on the inputs or parameters you might have. - -```php -$this->buildPath(string $path, array $parameters): string; -``` - -For example, if you want to build a path that has a dynamic id: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct() - { - parent::__construct(); - - // recommended config - $this->setBaseUrl('https://api.example.com/v1'); - } - - public function getPostComments(int $postId): string - { - // GET https://api.example.com/v1/posts/1/comments - return $this->request( - method: 'GET', - path: $this->buildPath('/posts/{postId}/comments', [ - 'postId' => $postId - ]) - ); - } -} -``` - -### Query Defaults - -These methods are used for handling default query parameters. -Default query parameters are applied to every API request. - -```php -$this->addQueryDefault(string $name, mixed $value): self -``` - -```php -$this->getQueryDefault(string $name): mixed -``` - -```php -$this->removeQueryDefault(string $name): self -``` - -For example, if you want to add a language query parameter in all requests: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct(string $language = 'en') - { - // ... - - $this->addQueryDefault('lang', $language); - } - - public function getPosts(): string - { - // GET https://api.example.com/v1/posts?lang=en - return $this->request( - method: 'GET', - path: '/posts' - ); - } - - public function getCategories(): string - { - // a query parameter with the same name, passed in the request method, will overwrite a query default - // GET https://api.example.com/v1/categories?lang=pt - return $this->request( - method: 'GET', - path: '/categories', - query: [ - 'lang' => 'pt' - ] - ); - } -} -``` - -> [!NOTE] -> A `query` parameter with the same name, passed in the `request` method, will overwrite a query default. -> Check the `getCategories` method in the example above. - -### Header Defaults - -These methods are used for handling default headers. -Default headers are applied to every API request. - -```php -$this->addHeaderDefault(string $name, mixed $value): self -``` - -```php -$this->getHeaderDefault(string $name): mixed -``` - -```php -$this->removeHeaderDefault(string $name): self -``` - -For example, if you want to add a language header value in all requests: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - public function __construct(string $language = 'en') - { - // ... - - $this->addHeaderDefault('X-LANGUAGE', $language); - } - - public function getPosts(): string - { - // GET https://api.example.com/v1/posts with an 'X-LANGUAGE' => 'en' header value - return $this->request( - method: 'GET', - path: '/posts' - ); - } - - public function getCategories(): string - { - // a header with the same name, passed in the request method, will overwrite a header default - // GET https://api.example.com/v1/categories with an 'X-LANGUAGE' => 'pt' header value - return $this->request( - method: 'GET', - path: '/categories', - headers: [ - 'X-LANGUAGE' => 'pt' - ] - ); - } -} -``` - -> [!NOTE] -> A header with the same name, passed in the `request` method, will overwrite a header default. -> Check the `getCategories` method in the example above. - -### Authentication +- SDK authors: developers creating concrete API SDKs with this library. +- SDK users: developers consuming those SDKs in applications. -Getter and setter for API authentication. -Uses the [authentication component](https://docs.php-http.org/en/latest/message/authentication.html) from [PHP HTTP](https://docs.php-http.org/en/latest/index.html). +The goal is to keep SDK authoring fluent and compact, keep SDK usage focused on real API resources, and still expose enough control for developers who need to customize or work around an SDK. -```php -use Http\Message\Authentication; +The practical guides below show how to build resources, map responses, and configure the HTTP pipeline. Read [Design Approach](docs/02-design-approach.md) for more about the reasoning behind the API shape. -$this->setAuthentication(?Authentication $authentication): self; -``` - -```php -use Http\Message\Authentication; - -$this->getAuthentication(): ?Authentication; -``` - -Check all available authentication methods in the [PHP HTTP documentation](https://docs.php-http.org/en/latest/message/authentication.html#authentication-methods). - -You can also [implement your own](https://docs.php-http.org/en/latest/message/authentication.html#implement-your-own) authentication method. - -For example, if you have an API authenticated with a query parameter: - -```php -use ProgrammatorDev\Api\Api; -use Http\Message\Authentication\QueryParam; - -class YourApi extends Api -{ - public function __construct(string $applicationKey) - { - // ... - - $this->setAuthentication( - new QueryParam([ - 'api_token' => $applicationKey - ]) - ); - } - - public function getPosts(): string - { - // GET https://api.example.com/v1/posts?api_token=cd982h3diwh98dd23d32j - return $this->request( - method: 'GET', - path: '/posts' - ); - } -} -``` - -### Event Listeners - -- [`addPreRequestListener`](#addprerequestlistener) -- [`addPostRequestListener`](#addpostrequestlistener) -- [`addResponseContentsListener`](#addresponsecontentslistener) -- [Event Priority](#event-priority) -- [Event Propagation](#event-propagation) - -#### `addPreRequestListener` - -The `addPreRequestListener` method is used to add a function called before a request has been made. -This event listener will be applied to every API request. - -```php -$this->addPreRequestListener(callable $listener, int $priority = 0): self; -``` - -For example: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\PreRequestEvent; - -class YourApi extends Api -{ - public function __construct() - { - // a PreRequestEvent is passed as an argument - $this->addPreRequestListener(function(PreRequestEvent $event) { - $request = $event->getRequest(); - - if ($request->getMethod() === 'POST') { - // do something for all POST requests - // ... - } - }); - } - - // ... -} -``` - -Available event methods: - -```php -$this->addPreRequestListener(function(PreRequestEvent $event) { - // get request data - $request = $event->getRequest(); - // ... - // set request data - $event->setRequest($request); -}); -``` - -#### `addPostRequestListener` - -The `addPostRequestListener` method is used to add a function called after a request has been made. -This function can be used to inspect the request and response data that was sent to, and received from, the API. -This event listener will be applied to every API request. - -```php -$this->addPostRequestListener(callable $listener, int $priority = 0): self; -``` - -For example, you can use this event listener to handle API errors: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\PostRequestEvent; - -class YourApi extends Api -{ - public function __construct() - { - // a PostRequestEvent is passed as an argument - $this->addPostRequestListener(function(PostRequestEvent $event) { - $response = $event->getResponse(); - $statusCode = $response->getStatusCode(); - - // if there was a response with an error status code - if ($statusCode >= 400) { - // throw an exception - match ($statusCode) { - 400 => throw new BadRequestException(), - 404 => throw new NotFoundException(), - default => throw new UnexpectedErrorException() - }; - } - }); - } - - // ... -} -``` - -Available event methods: - -```php -$this->addPostRequestListener(function(PostRequestEvent $event) { - // get request data - $request = $event->getRequest(); - // get response data - $response = $event->getResponse(); - // ... - // set response data - $event->setResponse($response); -}); -``` - -#### `addResponseContentsListener` - -The `addResponseContentsListener` method is used to manipulate the response received from the API. -This event listener will be applied to every API request. - -```php -$this->addResponseContentsListener(callable $listener, int $priority = 0): self; -``` - -For example, if the API responses are JSON strings, you can use this event listener to decode them into arrays: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\ResponseContentsEvent; - -class YourApi extends Api -{ - public function __construct() - { - // a ResponseContentsEvent is passed as an argument - $this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // get response contents and decode json string into an array - $contents = $event->getContents(); - $contents = json_decode($contents, true); - - // set handled contents - $event->setContents($contents); - }); - } - - public function getPosts(): array - { - // will return an array - return $this->request( - method: 'GET', - path: '/posts' - ); - } -} -``` - -Available event methods: - -```php -$this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // get response body contents data - $contents = $event->getContents(); - // ... - // set contents - $event->setContents($contents); -}); -``` - -#### Event Priority - -It is possible to add multiple listeners for the same event and set the order in which they will be executed. -By default, they will be executed in the same order as they are added, but you can set a `priority` to control that order. -Event listeners are then executed from the highest priority to the lowest: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\ResponseContentsEvent; - -class YourApi extends Api -{ - public function __construct() - { - // two event listeners are added, - // but the second is executed first (higher priority) even though it was added after - - // executed last (lower priority) - $this->addResponseContentsListener( - listener: function(ResponseContentsEvent $event) { ... }, - priority: 0 - ); - - // executed first (higher priority) - $this->addResponseContentsListener( - listener: function(ResponseContentsEvent $event) { ... }, - priority: 10 - ); - } -} -``` - -#### Event Propagation - -In some cases, you may want to stop the event flow and prevent listeners from being called. -For that, you can use the `stopPropagation()` method: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Event\ResponseContentsEvent; - -class YourApi extends Api -{ - public function __construct() - { - $this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // stop propagation so future listeners of this event will not be called - $event->stopPropagation(); - }); - - // this listener will not be called - $this->addResponseContentsListener(function(ResponseContentsEvent $event) { - // ... - }); - } -} -``` - -### HTTP Client (PSR-18) and HTTP Factories (PSR-17) - -- [HTTP client and HTTP factory adapters](#http-client-and-http-factory-adapters) -- [Plugin system](#plugin-system) - -#### HTTP Client and HTTP Factory Adapters - -By default, this library makes use of the [HTTPlug's Discovery](https://github.com/php-http/discovery) library. -This means that it will automatically find and install a well-known PSR-18 client and PSR-17 factory implementation for you -(if they were not found on your project): -- [PSR-18 compatible implementations](https://packagist.org/providers/psr/http-client-implementation) -- [PSR-17 compatible implementations](https://packagist.org/providers/psr/http-factory-implementation) - -```php -use ProgrammatorDev\Api\Builder\ClientBuilder; - -new ClientBuilder( - // a PSR-18 client - ?ClientInterface $client = null, - // a PSR-17 request factory - ?RequestFactoryInterface $requestFactory = null, - // a PSR-17 stream factory - ?StreamFactoryInterface $streamFactory = null -); -``` - -```php -use ProgrammatorDev\Api\Builder\ClientBuilder; - -$this->setClientBuilder(ClientBuilder $clientBuilder): self; -``` - -```php -use ProgrammatorDev\Api\Builder\ClientBuilder; - -$this->getClientBuilder(): ClientBuilder; -``` - -If you don't want to rely on the discovery of implementations, you can set the ones you want: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\ClientBuilder; -use Symfony\Component\HttpClient\Psr18Client; -use Nyholm\Psr7\Factory\Psr17Factory; - -class YourApi extends Api -{ - public function __construct() - { - // ... - - $client = new Psr18Client(); - $requestFactory = $streamFactory = new Psr17Factory(); - - $this->setClientBuilder( - new ClientBuilder( - client: $client, - requestFactory: $requestFactory, - streamFactory: $streamFactory - ) - ); - } -} -``` - -#### Plugin System - -This library enables attaching plugins to the HTTP client. -A plugin modifies the behavior of the client by intercepting the request and response flow. - -Since plugin order matters, a plugin is added with a priority level and is executed in descending order from highest to lowest. - -Check all the [available plugins](https://docs.php-http.org/en/latest/plugins/index.html) or [create your own](https://docs.php-http.org/en/latest/plugins/build-your-own.html). - -```php -use Http\Client\Common\Plugin; - -$this->getClientBuilder()->addPlugin(Plugin $plugin, int $priority): self; -``` - -It is important to know that this library already uses various plugins with different priorities. -The following list has all the implemented plugins with the respective priority in descending order (remember that order matters): - -| Plugin | Priority | Note | -|--------------------------------------------------------------------------------------------|----------|-----------------------------------| -| [`ContentTypePlugin`](https://docs.php-http.org/en/latest/plugins/content-type.html) | 40 | | -| [`ContentLengthPlugin`](https://docs.php-http.org/en/latest/plugins/content-length.html) | 32 | | -| [`AuthenticationPlugin`](https://docs.php-http.org/en/latest/plugins/authentication.html) | 24 | only if authentication is enabled | -| [`CachePlugin`](https://docs.php-http.org/en/latest/plugins/cache.html) | 16 | only if cache is enabled | -| [`LoggerPlugin`](https://docs.php-http.org/en/latest/plugins/logger.html) | 8 | only if logger is enabled | - -> [!IMPORTANT] -> The plugin priority in the list above is reserved. -> This means that if you try to add any plugin with the same priority, it will be overwritten. - -For example, if you wanted the client to automatically attempt to re-send a request that failed -(due to unreliable connections and servers, for example), you can add the [RetryPlugin](https://docs.php-http.org/en/latest/plugins/retry.html): - -```php -use ProgrammatorDev\Api\Api; -use Http\Client\Common\Plugin\RetryPlugin; - -class YourApi extends Api -{ - public function __construct() - { - // ... - - // if a request fails, it will retry at least 3 times - // the priority is 20 to execute before the cache plugin - // (check the above plugin order list for more information) - $this->getClientBuilder()->addPlugin( - plugin: new RetryPlugin(['retries' => 3]), - priority: 20 - ); - } -} -``` - -### Cache (PSR-6) - -This library allows configuring the cache layer of the client for making API requests. -It uses a standard PSR-6 implementation and provides methods to fine-tune how HTTP caching behaves: -- [PSR-6 compatible implementations](https://packagist.org/providers/psr/cache-implementation) - -```php -use ProgrammatorDev\Api\Builder\CacheBuilder; -use Psr\Cache\CacheItemPoolInterface; - -new CacheBuilder( - // a PSR-6 cache adapter - CacheItemPoolInterface $pool, - // default lifetime (in seconds) of cached items - ?int $ttl = 60, - // An array of HTTP methods for which caching should be applied - $methods = ['GET', 'HEAD'], - // An array of cache directives to be compared with the headers of the HTTP response to determine cacheability - $responseCacheDirectives = ['max-age'] -); -``` - -```php -use ProgrammatorDev\Api\Builder\CacheBuilder; - -$this->setCacheBuilder(CacheBuilder $cacheBuilder): self; -``` - -```php -use ProgrammatorDev\Api\Builder\CacheBuilder; - -$this->getCacheBuilder(): CacheBuilder; -``` - -For example, if you wanted to set a file-based cache adapter: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\CacheBuilder; -use Symfony\Component\Cache\Adapter\FilesystemAdapter; - -class YourApi extends Api -{ - public function __construct() - { - // ... - - $pool = new FilesystemAdapter(); - - // file-based cache adapter with a 1-hour default cache lifetime - $this->setCacheBuilder( - new CacheBuilder( - pool: $pool, - ttl: 3600 - ) - ); - } - - public function getPosts(): string - { - // you can change the lifetime (and all other parameters) - // for this specific endpoint - $this->getCacheBuilder()->setTtl(600); - - return $this->request( - method: 'GET', - path: '/posts' - ); - } -} -``` - -### Logger (PSR-3) - -This library allows configuring a logger to save data for making API requests. -It uses a standard PSR-3 implementation and provides methods to fine-tune how logging behaves: -- [PSR-3 compatible implementations](https://packagist.org/providers/psr/log-implementation) - -```php -use ProgrammatorDev\Api\Builder\LoggerBuilder; -use Psr\Log\LoggerInterface; -use Http\Message\Formatter; -use Http\Message\Formatter\SimpleFormatter; - -new LoggerBuilder( - // a PSR-3 logger adapter - LoggerInterface $logger, - // determines how the log entries will be formatted when they are written by the logger - // if no formatter is provided, it will default to a SimpleFormatter instance - ?Formatter $formatter = null -); -``` - -```php -use ProgrammatorDev\Api\Builder\LoggerBuilder; - -$this->setLoggerBuilder(LoggerBuilder $loggerBuilder): self; -``` +## Requirements -```php -use ProgrammatorDev\Api\Builder\LoggerBuilder; +- PHP `>=8.1` +- PSR-18 HTTP client support +- PSR-17 request and stream factory support -$this->getLoggerBuilder(): LoggerBuilder; -``` +The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. When the `php-http/discovery` Composer plugin is enabled, missing implementations can be installed automatically from the supported virtual packages. -As an example, if you wanted to save logs into a file: - -```php -use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\LoggerBuilder; -use Monolog\Logger; -use Monolog\Handler\StreamHandler; +## Installation -class YourApi extends Api -{ - public function __construct() - { - // ... - - $logger = new Logger('api'); - $logger->pushHandler(new StreamHandler('/logs/api.log')); - - $this->setLoggerBuilder( - new LoggerBuilder( - logger: $logger - ) - ); - } -} +```bash +composer require programmatordev/php-api-sdk ``` -## Libraries using PHP API SDK - -- [programmatordev/openweathermap-php-api](https://github.com/programmatordev/openweathermap-php-api) -- [programmatordev/sportmonksfootball-php-api](https://github.com/programmatordev/sportmonksfootball-php-api) +SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementations when they want tighter control over the default HTTP stack. -## Contributing +## Guides -Any form of contribution to improve this library (including requests) will be welcome and appreciated. -Make sure to open a pull request or issue. +- [Getting Started](docs/01-getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. +- [Design Approach](docs/02-design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. +- [API](docs/03-api.md): SDK facade setup methods, configuration, and extension points. +- [Resource Authoring](docs/04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific resource chains. +- [Resources](docs/05-resources.md): resource classes and endpoint request helpers. +- [Responses](docs/06-responses.md): decoded data, raw responses, entities, collections, envelopes, and context. +- [Authentication](docs/07-authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. +- [HTTP Client](docs/08-http-client.md): configure PSR-18 clients and PSR-17 factories. +- [Cache](docs/09-cache.md): configure PSR-6 HTTP response caching. +- [Logging](docs/10-logging.md): configure PSR-3 logging and HTTP/cache log output. +- [Plugins](docs/11-plugins.md): configure HTTPlug middleware and priority ordering. +- [Hooks](docs/12-hooks.md): run SDK-author callbacks around requests and responses. -## License +## Upgrading -This project is licensed under the MIT license. -Please see the [LICENSE](LICENSE) file distributed with this source code for further information regarding copyright and licensing. \ No newline at end of file +See [Upgrade to 3.0](UPGRADE-3.0.md) for the high-level changes. diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md new file mode 100644 index 0000000..fe5b346 --- /dev/null +++ b/UPGRADE-3.0.md @@ -0,0 +1,197 @@ +# Upgrade to 3.0 + +Version 3.0 is a full architecture refresh. This is not a step-by-step migration guide because most SDKs should be reshaped around the new authoring model instead of mechanically replacing old calls. + +Use this document as a short summary of what changed and where to look when updating an SDK. + +## Resources Use Endpoint Builders + +Request helpers now live behind `endpoint()` inside resources. + +```php +return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); +``` + +Use endpoint modifiers for request-local state: + +```php +return $this + ->endpoint() + ->query('active', true) + ->header('X-Tenant', $tenant) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +See [Resource Authoring: Endpoint Requests](docs/04-resource-authoring.md#endpoint-requests), [Resource Authoring: Query And Headers](docs/04-resource-authoring.md#query-and-headers), and [Resources: Endpoint HTTP Methods](docs/05-resources.md#endpoint-http-methods) for details. + +## Responses Are First-Class + +Entities must implement `EntityInterface`: + +```php +public static function fromArray(array $data, ?Context $context = null): static; +``` + +Use response mapping helpers: + +```php +$response->entity(User::class); +$response->collection(User::class, key: 'data'); +$response->envelope(UserEnvelope::class); +``` + +Envelopes must implement `EnvelopeInterface`. + +See [Responses: EntityInterface](docs/06-responses.md#entityinterface) and [Responses: EnvelopeInterface](docs/06-responses.md#envelopeinterface) for details. + +## Config Replaces Ad Hoc Options + +SDK options should use `config()`: + +```php +$this->config($options, defaults: [ + 'timezone' => 'UTC', +]); +``` + +The same config is available to entities, envelopes, and hooks through context objects. + +See [API](docs/03-api.md) and [Responses: Context](docs/06-responses.md#context) for details. + +## Decoding And Errors Are First-Class + +Response decoding is configured with `responses()`: + +```php +$this->responses()->json(); +$this->responses()->xml(); +$this->responses()->custom($decoder); +``` + +HTTP errors do not throw by default. Configure error handling explicitly: + +```php +$this->errors()->status(404, NotFoundException::class); +``` + +See [API](docs/03-api.md) and [Responses](docs/06-responses.md) for details. + +## Infrastructure Uses Builders + +PSR-18 clients, PSR-17 factories, PSR-6 cache, PSR-3 logging, HTTPlug authentication, plugins, and hooks are still supported. They are configured through grouped builders instead of scattered low-level methods. + +```php +$this->auth()->bearer($token); +$this->cache($pool)->defaultTtl(3600); +$this->logger($logger); +$this->plugins()->add($plugin); +$this->hooks()->beforeRequest($hook); +$this->client($client)->requestFactory($requestFactory); +``` + +`plugins()` remains the right place for transport-level behavior. `hooks()` remains available for request and response lifecycle customization, but response decoding and error handling now have dedicated builders. + +Most SDKs only need one authentication helper: + +```php +$this->auth()->bearer($token); +``` + +Use `chain()` only when an API requires multiple authentication rules on the same request. + +See [Authentication](docs/07-authentication.md), [HTTP Client](docs/08-http-client.md), [Cache](docs/09-cache.md), [Logging](docs/10-logging.md), [Plugins](docs/11-plugins.md), and [Hooks](docs/12-hooks.md) for details. + +## Defaults And Endpoint Overrides + +SDK authors can still configure request defaults: + +```php +$this->defaultHeaders(['Accept' => 'application/json']); +$this->defaultQueries($this->config()->only('units', 'locale')); +``` + +SDK authors can configure endpoint-specific cache defaults inside the endpoint chain: + +```php +use ProgrammatorDev\Api\Builder\CacheBuilder; + +return $this + ->endpoint() + ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->get('/live') + ->collection(Event::class, key: 'data'); +``` + +SDK users can override that cache behavior for one resource chain with `withCache()`: + +```php +$events = $api + ->events() + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(30)) + ->live(); +``` + +Cache precedence is: + +```text +API cache config < endpoint cache defaults < resource withCache override +``` + +The base package provides the generic override mechanism. API-specific fluent helpers, such as `withIncludes()` or `withStatus()`, should live in the concrete SDK. + +See [Resource Authoring: API-Specific Resource Chains](docs/04-resource-authoring.md#api-specific-resource-chains), [Resources: Resource Cache Overrides](docs/05-resources.md#resource-cache-overrides), [Cache: Endpoint Defaults](docs/09-cache.md#endpoint-defaults), and [Cache: Resource Overrides](docs/09-cache.md#resource-overrides) for details. + +## Setup Is The Escape Hatch + +Most SDK-user customization now goes through `setup()`: + +```php +$api->setup()->client($client); +$api->setup()->plugins()->add($plugin); +$api->setup()->auth()->bearer($token); +``` + +SDK authors still configure defaults from the `Api` subclass with protected helpers such as `baseUrl()`, `defaultQueries()`, `auth()`, `responses()`, `errors()`, `cache()`, `logger()`, `plugins()`, and `hooks()`. + +See [API](docs/03-api.md) and [Design Approach: Escape Hatch](docs/02-design-approach.md#escape-hatch) for details. + +## Send Is Public + +`send()` is public as an advanced escape hatch. SDK users can call endpoints that are not modeled by the concrete SDK while still using the SDK's configured base URL, authentication, cache, plugins, hooks, decoding, and error handling. + +```php +$response = $api->send('GET', '/unmodeled-endpoint', query: [ + 'page' => 1, +]); +``` + +See [API](docs/03-api.md) for details. + +## HTTP Client Discovery + +The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. When the `php-http/discovery` Composer plugin is enabled, missing implementations can be installed automatically from the supported virtual packages. + +SDK authors may still require or suggest concrete implementations when they want control over the default HTTP stack. + +See [HTTP Client: SDK Author Defaults](docs/08-http-client.md#sdk-author-defaults) and [HTTP Client: SDK User Overrides](docs/08-http-client.md#sdk-user-overrides) for details. + +## API-Specific Behavior Belongs In SDKs + +API-specific options such as includes, filters, selects, or pagination should be implemented in concrete SDK resources, not in the base package. + +```php +$users = $api + ->users() + ->withStatus('active') + ->all(); +``` + +See [Resource Authoring: API-Specific Resource Chains](docs/04-resource-authoring.md#api-specific-resource-chains) for details. + +## Test Utilities Are Support Code + +The test helpers are intended to support this package and SDK author tests. Concrete SDKs should prefer focused tests around their own resources, entities, envelopes, fake clients, and API-specific fluent helpers. diff --git a/composer.json b/composer.json index 280be3a..36ba99b 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,9 @@ { "name": "programmatordev/php-api-sdk", - "description": "A library for creating SDKs in PHP with PSR-18, PSR-17, PSR-6 and PSR-3 support", + "description": "A fluent PHP library for creating API SDKs with PSR-18, PSR-17, PSR-6 and PSR-3 support", "type": "library", - "keywords": ["php-sdk", "php-api", "php8", "psr-18", "psr-17", "psr-6", "psr-3"], + "keywords": ["api", "api-client", "api-sdk", "http-client", "php-api", "php-sdk", "psr-3", "psr-6", "psr-17", "psr-18", "sdk"], + "homepage": "https://github.com/programmatordev/php-api-sdk", "license": "MIT", "authors": [ { @@ -11,24 +12,28 @@ "homepage": "https://programmator.dev" } ], + "support": { + "issues": "https://github.com/programmatordev/php-api-sdk/issues", + "source": "https://github.com/programmatordev/php-api-sdk" + }, "require": { "php": ">=8.1", + "ext-simplexml": "*", "nyholm/append-query-string": "^1.0", - "php-http/cache-plugin": "^2.0", + "php-http/cache-plugin": "^2.1", "php-http/client-common": "^2.7", "php-http/discovery": "^1.20", "php-http/logger-plugin": "^1.3", "php-http/message": "^1.16", "psr/cache": "^2.0|^3.0", "psr/http-client": "^1.0", - "psr/http-client-implementation": "*", + "psr/http-client-implementation": "^1.0", "psr/http-factory": "^1.1", - "psr/http-factory-implementation": "*", - "psr/log": "^2.0|^3.0", - "symfony/event-dispatcher": "^6.4|^7.4|^8.0" + "psr/http-factory-implementation": "^1.0", + "psr/log": "^2.0|^3.0" }, "require-dev": { - "monolog/monolog": "^3.9", + "monolog/monolog": "^3.10", "nyholm/psr7": "^1.8", "php-http/mock-client": "^1.6", "phpunit/phpunit": "^10.5", @@ -36,10 +41,6 @@ "symfony/http-client": "^6.4|^7.4|^8.0", "symfony/var-dumper": "^6.4|^7.4|^8.0" }, - "provide": { - "psr/http-client-implementation": "1.0", - "psr/http-factory-implementation": "1.0" - }, "autoload": { "psr-4": { "ProgrammatorDev\\Api\\": "src/" diff --git a/docs/00-index.md b/docs/00-index.md new file mode 100644 index 0000000..33374c7 --- /dev/null +++ b/docs/00-index.md @@ -0,0 +1,51 @@ +# Documentation + +These docs describe how to create API SDKs with this package. + +This package is built for two developer audiences: + +- SDK authors: developers creating concrete API SDKs with this library. +- SDK users: developers consuming those SDKs in applications. + +The goal is to keep SDK authoring fluent and compact, keep SDK usage focused on real API resources, and still expose enough control for developers who need to customize or work around an SDK. + +The practical guides below show how to build resources, map responses, and configure the HTTP pipeline. Read [Design Approach](02-design-approach.md) for more about the reasoning behind the API shape. + +## Requirements + +- PHP `>=8.1` +- PSR-18 HTTP client support +- PSR-17 request and stream factory support + +The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. When the `php-http/discovery` Composer plugin is enabled, missing implementations can be installed automatically from the supported virtual packages. + +## Installation + +```bash +composer require programmatordev/php-api-sdk +``` + +SDK packages may still require or suggest concrete PSR-18 and PSR-17 implementations when they want tighter control over the default HTTP stack. + +## Guides + +- [Getting Started](01-getting-started.md): create a small SDK with an API facade, resource, entity, and response mapping. +- [Design Approach](02-design-approach.md): the reasoning behind fluent SDK authoring, clean SDK usage, and hackability. +- [API](03-api.md): SDK facade setup methods, configuration, and extension points. +- [Resource Authoring](04-resource-authoring.md): deeper guide for resource methods, query/header options, request bodies, entity mapping, collections, envelopes, and API-specific resource chains. +- [Resources](05-resources.md): resource classes and endpoint request helpers. +- [Responses](06-responses.md): decoded data, raw responses, entities, collections, envelopes, and context. +- [Authentication](07-authentication.md): configure bearer, basic, header, query, HTTPlug, and custom authentication. +- [HTTP Client](08-http-client.md): configure PSR-18 clients and PSR-17 factories. +- [Cache](09-cache.md): configure PSR-6 HTTP response caching. +- [Logging](10-logging.md): configure PSR-3 logging and HTTP/cache log output. +- [Plugins](11-plugins.md): configure HTTPlug middleware and priority ordering. +- [Hooks](12-hooks.md): run SDK-author callbacks around requests and responses. + +## Upgrading + +See [Upgrade to 3.0](../UPGRADE-3.0.md) for the high-level changes. + +## Navigation + +- Next: [Getting Started](01-getting-started.md) diff --git a/docs/01-getting-started.md b/docs/01-getting-started.md new file mode 100644 index 0000000..37f9a13 --- /dev/null +++ b/docs/01-getting-started.md @@ -0,0 +1,161 @@ +# Getting Started + +This guide builds a small SDK with one entity, one resource, and one API facade. + +## Install + +```bash +composer require programmatordev/php-api-sdk +``` + +The package uses PHP-HTTP discovery for PSR-18 clients and PSR-17 factories. When the `php-http/discovery` Composer plugin is enabled, missing implementations can be installed automatically. SDK packages may still require or suggest concrete implementations when they want tighter control over the default HTTP stack. + +## Create An Entity + +Entities are typed response objects. Classes used with `Response::entity()` and `Response::collection()` must implement `EntityInterface`. + +`fromArray()` is the SDK author's mapping boundary: it defines how decoded API data becomes the entity. + +```php +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; + +final class User implements EntityInterface +{ + public function __construct( + public readonly int $id, + public readonly string $name, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + return new self( + id: $data['id'], + name: $data['name'], + ); + } +} +``` + +## Create A Resource + +Resources group endpoint methods. Use `endpoint()` to start a request, execute it, and map the response. + +```php +use ProgrammatorDev\Api\Resource; + +final class UserResource extends Resource +{ + public function find(int $id): User + { + return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + + /** + * @return User[] + */ + public function all(): array + { + return $this + ->endpoint() + ->get('/users') + ->collection(User::class, key: 'data'); + } + + public function create(string $name): User + { + return $this + ->endpoint() + ->json(['name' => $name]) + ->post('/users') + ->entity(User::class, key: 'data'); + } +} +``` + +See [Resource Authoring](04-resource-authoring.md) for query parameters, headers, request bodies, cache overrides, and API-specific fluent chains. + +## Create An API Class + +The API class is the SDK facade. It configures shared options and exposes purpose-built resources. + +```php +use ProgrammatorDev\Api\Api; + +final class ExampleApi extends Api +{ + public function __construct(string $apiKey) + { + parent::__construct(); + + $this->baseUrl('https://api.example.com'); + $this->auth()->query('api_key', $apiKey); + } + + public function users(): UserResource + { + return $this->resource(UserResource::class); + } +} +``` + +SDK users work with resources and endpoint methods, not raw request execution: + +```php +$api = new ExampleApi('secret'); + +$user = $api->users()->find(1); +$users = $api->users()->all(); +``` + +## Map Envelopes + +If an API returns metadata, pagination, or any custom envelope, create an envelope class. + +```php +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Response\Response; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; + +final class UserEnvelope implements EnvelopeInterface +{ + public function __construct( + /** @var User[] */ + public readonly array $users, + public readonly int $page, + public readonly int $totalPages, + ) {} + + public static function fromResponse(Response $response, ?Context $context = null): static + { + $data = $response->data(); + + return new self( + users: $response->collection(User::class, key: 'data'), + page: $data['pagination']['page'], + totalPages: $data['pagination']['total_pages'], + ); + } +} +``` + +Then return it from the resource: + +```php +public function all(int $page = 1): UserEnvelope +{ + return $this + ->endpoint() + ->query('page', $page) + ->get('/users') + ->envelope(UserEnvelope::class); +} +``` + +## Navigation + +- Previous: [Documentation](00-index.md) +- Next: [Design Approach](02-design-approach.md) diff --git a/docs/02-design-approach.md b/docs/02-design-approach.md new file mode 100644 index 0000000..9a0441e --- /dev/null +++ b/docs/02-design-approach.md @@ -0,0 +1,82 @@ +# Design Approach + +This package is built for two developer audiences: + +- SDK authors: developers creating concrete API SDKs with this library. +- SDK users: developers consuming those SDKs in applications. + +The goal is to keep SDK authoring fluent and compact, keep SDK usage focused on real API resources, and still expose enough control for developers who need to customize or work around an SDK. + +## SDK Authors + +SDK authors should usually work inside an `Api` subclass and resources. + +```php +final class ExampleApi extends Api +{ + public function __construct(string $apiKey) + { + $this->baseUrl('https://api.example.com'); + $this->responses()->json(); + $this->auth()->query('api_key', $apiKey); + } + + public function users(): UserResource + { + return $this->resource(UserResource::class); + } +} +``` + +Resources should make endpoint methods feel direct: + +```php +final class UserResource extends Resource +{ + public function find(int $id): User + { + return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class, key: 'data'); + } +} +``` + +## SDK Users + +SDK users should mostly see the API that the SDK author created: + +```php +$user = $api->users()->find(1); +``` + +Advanced setup is still available through one explicit entry point: + +```php +$api->setup()->client($client); +$api->setup()->plugins()->add($plugin); +$api->setup()->auth()->bearer($token); +``` + +This keeps the main SDK autocomplete focused while preserving hackability. + +## Escape Hatch + +If a concrete SDK does not expose an endpoint yet, `send()` can still use the configured SDK pipeline: + +```php +$response = $api->send( + method: 'GET', + path: '/new-endpoint/{id}', + pathParams: ['id' => 1], + query: ['include' => 'details'] +); +``` + +That request still uses configured base URL, defaults, auth, plugins, cache, hooks, response decoding, and error handling. + +## Navigation + +- Previous: [Getting Started](01-getting-started.md) +- Next: [API](03-api.md) diff --git a/docs/03-api.md b/docs/03-api.md new file mode 100644 index 0000000..e97487e --- /dev/null +++ b/docs/03-api.md @@ -0,0 +1,437 @@ +# API + +`Api` is the SDK facade. Concrete SDKs extend it and expose resources through purpose-built methods. + +This page documents the public API facade methods available to SDK authors and advanced SDK users. + +## Request Execution + +### `send()` + +```php +send( + string $method, + string $path, + array $pathParams = [], + array $query = [], + array $headers = [], + string|StreamInterface|null $body = null, +): Response +``` + +Public low-level request helper. + +Most SDK methods should use resources and endpoint request helpers. `send()` is useful when an SDK author or advanced SDK user needs to execute a request directly while still using the configured base URL, defaults, auth, plugins, cache, hooks, decoding, and errors. + +```php +$response = $api->send('GET', '/users/{id}', ['id' => 1]); +``` + +Common request inputs can be passed directly: + +```php +$response = $api->send( + method: 'POST', + path: '/users', + query: ['active' => true], + headers: ['Content-Type' => 'application/json'], + body: '{"name":"John"}' +); +``` + +Path parameters are encoded and replaced in `{name}` placeholders. + +`send()` still runs through the configured SDK pipeline: + +- Base URL, default query parameters, and default headers. +- Authentication, plugins, cache, and hooks. +- Response decoding and error mapping. + +## SDK Setup + +### `config()` + +```php +config(array $values = [], array $defaults = []): Config +``` + +Public. + +Merges SDK options when values or defaults are provided and always returns the config bag. Defaults are merged first, so explicit values override them. + +```php +$this->config( + ['timezone' => 'UTC'], + defaults: ['timezone' => 'Europe/Lisbon'] +); + +$timezone = $this->config()->get('timezone'); +``` + +SDK users can also read or update options: + +```php +$api->config(['timezone' => 'UTC']); +$api->config()->get('timezone'); +``` + +### `setup()` + +```php +setup(): Setup +``` + +Public access to SDK setup and extension points without adding every setup method to the concrete SDK surface. + +```php +$api->setup()->plugins()->add($plugin); +$api->setup()->client($client); +$api->setup()->auth()->bearer($token); +``` + +SDK authors can call the same setup methods directly from subclasses. + +### `resource()` + +```php +resource(string $class): Resource +``` + +Protected helper for creating resource instances from an API class. + +See [Resource Authoring](04-resource-authoring.md) for the recommended API-to-resource pattern. + +## Request Defaults + +### `baseUrl()` + +```php +baseUrl(?string $baseUrl): static +``` + +Protected fluent helper for configuring the API base URL. + +```php +$this->baseUrl('https://api.example.com'); +``` + +Full request URLs passed to resources override the configured base URL. + +### `defaultQuery()` + +```php +defaultQuery(string $name, mixed $value): static +``` + +Protected fluent helper for configuring one query parameter applied to every request. + +```php +$this->defaultQuery('api_key', $apiKey); +``` + +### `defaultQueries()` + +```php +defaultQueries(array $query): static +``` + +Protected fluent helper for configuring query parameters applied to every request. + +```php +$this->defaultQueries(['api_key' => $apiKey, 'locale' => 'en']); +``` + +Query merge order is: + +```text +API defaults < endpoint options +``` + +### `defaultHeader()` + +```php +defaultHeader(string $name, mixed $value): static +``` + +Protected fluent helper for configuring one header applied to every request. + +```php +$this->defaultHeader('Accept', 'application/json'); +``` + +### `defaultHeaders()` + +```php +defaultHeaders(array $headers): static +``` + +Protected fluent helper for configuring headers applied to every request. + +```php +$this->defaultHeaders(['Accept' => 'application/json']); +``` + +Header names are not normalized by the package. + +## Pipeline Builders + +### `auth()` + +```php +auth(): AuthBuilder +``` + +Protected access to authentication configuration. + +```php +$this->auth()->bearer($token); +``` + +Authentication is applied automatically to outgoing requests. + +Calling another auth helper replaces the previous authentication. Use `chain()` when multiple authentication rules are required. + +See [Authentication](07-authentication.md) for helper methods, HTTPlug authentication objects, and custom auth callbacks. + +### `hooks()` + +```php +hooks(): HookBuilder +``` + +Protected access to request and response hooks. SDK users can access hooks through `setup()`. + +```php +$this->hooks()->beforeRequest($hook); +$this->hooks()->afterResponse($hook); +``` + +Hooks are SDK-author extension points. They run around the raw HTTP request and response, before response decoding and error handling. + +See [Hooks](12-hooks.md) for hook context objects, return values, and priority behavior. + +### `plugins()` + +```php +plugins(): PluginBuilder +``` + +Protected access to HTTPlug plugin configuration. SDK users can access plugins through `setup()`. + +```php +$this->plugins()->add($plugin, priority: 16); +``` + +Higher priority plugins run earlier. Same-priority plugins are preserved in insertion order. + +See [Plugins](11-plugins.md) for internal plugin order and priority guidance. + +### `cache()` + +```php +cache(CacheItemPoolInterface $pool): CacheBuilder +``` + +Protected access to PSR-6 HTTP response cache configuration. SDK users can access cache through `setup()`. + +```php +$this + ->cache($pool) + ->defaultTtl(3600) + ->methods(['GET', 'HEAD']); +``` + +See [Cache](09-cache.md) for cache options and plugin order. + +### `client()` + +```php +client(ClientInterface $client): ClientBuilder +``` + +Protected access to PSR-18 client configuration. SDK users can access client configuration through `setup()`. + +```php +$this->client($client); +``` + +SDK authors can configure PSR-17 factories on the returned builder: + +```php +$this + ->client($client) + ->requestFactory($requestFactory) + ->streamFactory($streamFactory); +``` + +See [HTTP Client](08-http-client.md) for client and factory configuration. + +### `logger()` + +```php +logger(LoggerInterface $logger): LoggerBuilder +``` + +Protected access to PSR-3 logger configuration. SDK users can access logging through `setup()`. + +```php +$this + ->logger($logger) + ->formatter($formatter); +``` + +See [Logging](10-logging.md) for logger formatting and cache logging. + +## Response Handling + +### `responses()` + +```php +responses(): ResponseBuilder +``` + +Protected access to response decoding configuration. + +```php +$this->responses()->json(); +$this->responses()->xml(); +$this->responses()->custom($decoder); +``` + +Available response formats: + +- `raw()`: response bodies are returned as strings. +- `json()`: response bodies are decoded into arrays; empty bodies become `null`; invalid JSON throws `JsonException`. +- `xml()`: response bodies are decoded into `SimpleXMLElement`; empty bodies become `null`; invalid XML throws `RuntimeException`. +- `custom()`: receives the raw PSR response and returns the value used as `Response::data()`. + +When no format is configured, `raw()` is used. + +### `errors()` + +```php +errors(): ErrorBuilder +``` + +Protected access to error handling configuration. + +By default, HTTP error status codes do not throw. SDK authors opt in to error behavior: + +```php +$this->errors()->status(404, NotFoundException::class); +``` + +Use `statuses()` when an SDK has a common status-to-exception map: + +```php +$this->errors()->statuses([ + 400 => BadRequestException::class, + 401 => UnauthorizedException::class, + 403 => ForbiddenException::class, + 404 => NotFoundException::class, + 429 => TooManyRequestsException::class, +]); +``` + +Use a callback when the exception needs response data: + +```php +$this->errors()->status(404, function (ErrorContext $context): Throwable { + return new NotFoundException($context->response()->data()['message']); +}); +``` + +Use `when()` for API-specific error payloads that are not represented by status alone: + +```php +$this->errors()->when(function (ErrorContext $context): ?Throwable { + if (($context->response()->data()['code'] ?? null) !== 'invalid_api_key') { + return null; + } + + return new InvalidApiKeyException($context->response()->data()['message']); +}); +``` + +Status callbacks receive `ErrorContext` and must return a `Throwable`. Custom `when()` handlers receive `ErrorContext` and must return a `Throwable` when matched or `null` when not matched. + +## Config Object + +`Config` stores SDK options. + +### `all()` + +```php +all(): array +``` + +Returns all option values. + +```php +$options = $api->config()->all(); +``` + +### `only()` + +```php +only(string ...$keys): array +``` + +Returns selected option values. Missing keys are omitted. + +```php +$query = $api->config()->only('units', 'lang'); +``` + +### `has()` + +```php +has(string $key): bool +``` + +Checks whether an option exists. A key with a `null` value still exists. + +```php +$api->config()->has('timezone'); +``` + +### `get()` + +```php +get(string $key, mixed $default = null): mixed +``` + +Returns an option value or the default when the key does not exist. + +```php +$timezone = $api->config()->get('timezone', 'UTC'); +``` + +### `set()` + +```php +set(string $key, mixed $value): self +``` + +Sets one option value. + +```php +$api->config()->set('timezone', 'UTC'); +``` + +### `merge()` + +```php +merge(array $values): self +``` + +Sets multiple option values. + +```php +$api->config()->merge(['timezone' => 'UTC', 'units' => 'metric']); +``` + +## Navigation + +- Previous: [Design Approach](02-design-approach.md) +- Next: [Resource Authoring](04-resource-authoring.md) diff --git a/docs/04-resource-authoring.md b/docs/04-resource-authoring.md new file mode 100644 index 0000000..c979902 --- /dev/null +++ b/docs/04-resource-authoring.md @@ -0,0 +1,384 @@ +# Resource Authoring + +Resources are the main authoring surface for SDK developers. They group endpoints and hide low-level request execution from the final SDK user. + +The typical endpoint method should stay compact: + +```php +final class UserResource extends Resource +{ + public function find(int $id): User + { + return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } +} +``` + +SDK users call purpose-built methods: + +```php +$user = $api->users()->find(1); +``` + +## Resources From The API + +Concrete SDKs should expose resources through methods on their API class: + +```php +final class ExampleApi extends Api +{ + public function users(): UserResource + { + return $this->resource(UserResource::class); + } +} +``` + +`Api::resource()` creates a fresh resource instance. Resource-chain infrastructure overrides, such as `withCache()`, are immutable, so fluent customizations do not leak into later calls. + +## Endpoint Requests + +Use `endpoint()` inside resource methods to create the request builder: + +```php +$endpoint = $this->endpoint(); + +$endpoint->get('/users'); +$endpoint->post('/users'); +$endpoint->put('/users/{id}', ['id' => $id]); +$endpoint->patch('/users/{id}', ['id' => $id]); +$endpoint->delete('/users/{id}', ['id' => $id]); +$endpoint->head('/users'); +$endpoint->options('/users'); +$endpoint->connect('/users'); +$endpoint->trace('/users'); +``` + +Each helper executes the request immediately and returns a `Response` wrapper. + +Endpoint-specific query parameters are configured on the endpoint builder: + +```php +return $this + ->endpoint() + ->query('locale', 'pt') + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); +``` + +Path parameters are encoded with `rawurlencode`. + +## Query And Headers + +Use endpoint modifiers for request-local options: + +```php +return $this + ->endpoint() + ->query('active', true) + ->header('X-Tenant', $tenant) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +Multiple values can be set with `queries()` and `headers()`: + +```php +return $this + ->endpoint() + ->queries(['active' => true, 'locale' => 'pt']) + ->headers(['X-Tenant' => $tenant]) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +SDK-user customization should be explicit in the resource method API. If a method argument is enough, prefer that over hidden resource state: + +```php +final class UserResource extends Resource +{ + public function all(bool $active = true): array + { + return $this + ->endpoint() + ->query('active', $active) + ->get('/users') + ->collection(User::class, key: 'data'); + } +} +``` + +SDK users call the public method chosen by the SDK author: + +```php +$users = $api->users()->all(active: true); +``` + +Query merge order is: + +```text +API defaults < endpoint options < endpoint method query argument +``` + +Null query values are omitted. Boolean and array query values use PHP's standard `http_build_query` behavior. + +## Request Bodies + +Use explicit body helpers for structured request data: + +```php +return $this + ->endpoint() + ->json(['name' => 'John']) + ->post('/users') + ->entity(User::class); +``` + +`json()` encodes the array as JSON and sets `Content-Type: application/json`. + +```php +return $this + ->endpoint() + ->form(['name' => 'John Doe']) + ->post('/users') + ->entity(User::class); +``` + +`form()` encodes the array with `http_build_query` and sets `Content-Type: application/x-www-form-urlencoded`. + +Use `body()` for raw string or PSR-7 stream bodies: + +```php +return $this + ->endpoint() + ->body($stream) + ->post('/uploads') + ->raw(); +``` + +`body()` does not guess the content type. Passing an array to `body()` throws; use `json()` or `form()` instead. + +## Response Mapping + +Use `entity()` when the endpoint returns one typed object: + +```php +return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); +``` + +Entities must implement `EntityInterface`: + +```php +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; + +final class User implements EntityInterface +{ + public static function fromArray(array $data, ?Context $context = null): static + { + return new self( + id: $data['id'], + name: $data['name'], + ); + } +} +``` + +Treat `fromArray()` as the entity's mapping boundary. It keeps resource methods focused on requests and lets each entity own how decoded API payloads become typed PHP values. + +Use the optional `key` argument when the object is nested inside an envelope: + +```php +return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class, key: 'data'); +``` + +Use `collection()` when the endpoint returns a list: + +```php +return $this + ->endpoint() + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +`collection()` returns a plain array of entities. + +Use `envelope()` when the response carries metadata, pagination, or any API-specific envelope: + +```php +return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->envelope(UserEnvelope::class); +``` + +Envelope classes must implement `EnvelopeInterface`: + +```php +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Response\Response; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; + +final class UserEnvelope implements EnvelopeInterface +{ + public function __construct( + private readonly User $user, + private readonly int $statusCode, + ) {} + + public static function fromResponse(Response $response, ?Context $context = null): static + { + return new self( + user: $response->entity(User::class, key: 'data'), + statusCode: $response->raw()->getStatusCode(), + ); + } +} +``` + +## Context + +`Context` carries SDK options into response mapping without passing the full `Api` instance around. + +The flow is: + +```text +SDK constructor options -> Api config -> Context -> EntityInterface or EnvelopeInterface +``` + +Start by accepting SDK options and storing them in config: + +```php +final class ExampleApi extends Api +{ + public function __construct(string $apiKey, array $options = []) + { + parent::__construct(); + + $this->baseUrl('https://api.example.com'); + $this->config($options, defaults: [ + 'timezone' => 'UTC', + ]); + } +} +``` + +When a response is mapped, the SDK creates a context with that config. The same context is passed to: + +- `EntityInterface::fromArray(array $data, ?Context $context = null)` +- `EnvelopeInterface::fromResponse(Response $response, ?Context $context = null)` + +Entities can use config values during hydration: + +```php +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Contract\EntityInterface; + +final class User implements EntityInterface +{ + public function __construct( + private readonly int $id, + private readonly string $name, + private readonly ?string $timezone, + ) {} + + public static function fromArray(array $data, ?Context $context = null): static + { + return new self( + id: $data['id'], + name: $data['name'], + timezone: $context?->config()->get('timezone'), + ); + } +} +``` + +Envelopes receive the same context: + +```php +use ProgrammatorDev\Api\Context\Context; +use ProgrammatorDev\Api\Response\Response; +use ProgrammatorDev\Api\Contract\EnvelopeInterface; + +final class UserEnvelope implements EnvelopeInterface +{ + public function __construct( + private readonly User $user, + private readonly ?string $timezone, + ) {} + + public static function fromResponse(Response $response, ?Context $context = null): static + { + return new self( + user: $response->entity(User::class, key: 'data'), + timezone: $context?->config()->get('timezone'), + ); + } +} +``` + +Keep context usage focused on hydration decisions. Entities should still be data/value objects by default and should not perform hidden network calls. + +## API-Specific Resource Chains + +Keep API-specific vocabulary out of the base package. Add it in SDK resources with small fluent methods that use the generic endpoint helpers underneath. + +For example, an SDK can expose a reusable status filter without making the generic package know what a status filter is: + +```php +trait HasStatusFilter +{ + private ?string $status = null; + + public function withStatus(string $status): static + { + $clone = clone $this; + $clone->status = $status; + + return $clone; + } +} +``` + +Resources that support that API-specific filter can opt in to the trait: + +```php +final class UserResource extends Resource +{ + use HasStatusFilter; + + public function all(): array + { + return $this + ->endpoint() + ->query('status', $this->status) + ->get('/users') + ->collection(User::class, key: 'data'); + } +} +``` + +SDK users get a fluent API-specific chain: + +```php +$users = $api + ->users() + ->withStatus('active') + ->all(); +``` + +Use the same pattern for API-specific concepts such as includes, filters, selects, pagination options, or locale settings. Clone the resource in `with*` methods so a configured chain does not leak into later calls. + +## Navigation + +- Previous: [API](03-api.md) +- Next: [Resources](05-resources.md) diff --git a/docs/05-resources.md b/docs/05-resources.md new file mode 100644 index 0000000..238b19d --- /dev/null +++ b/docs/05-resources.md @@ -0,0 +1,207 @@ +# Resources + +`Resource` is the base class for endpoint groups. + +`Resource` keeps the SDK-user-facing domain surface small. SDK resource classes call `endpoint()` to start an endpoint request builder. + +## Endpoint Builder + +### `endpoint()` + +```php +endpoint(): Endpoint +``` + +Protected SDK-author helper. + +Returns a fresh endpoint request builder. + +```php +return $this + ->endpoint() + ->get('/users') + ->raw(); +``` + +## Endpoint Body Helpers + +Endpoint body helpers are immutable and return a cloned endpoint builder. + +### `json()` + +```php +json(array $data): static +``` + +Sets a JSON request body and `Content-Type: application/json`. + +```php +return $this + ->endpoint() + ->json(['name' => 'John']) + ->post('/users') + ->entity(User::class); +``` + +### `form()` + +```php +form(array $data): static +``` + +Sets a form-encoded request body and `Content-Type: application/x-www-form-urlencoded`. + +```php +return $this + ->endpoint() + ->form(['name' => 'John Doe']) + ->post('/users') + ->entity(User::class); +``` + +### `body()` + +```php +body(mixed $body): static +``` + +Sets a raw string, stream, or null request body. + +```php +return $this + ->endpoint() + ->body($stream) + ->post('/uploads') + ->raw(); +``` + +Passing an array throws. Use `json()` or `form()` for array data. + +## Endpoint Query And Headers + +### `query()` + +```php +query(string $name, mixed $value): static +``` + +Sets one endpoint-local query option. + +```php +return $this + ->endpoint() + ->query('active', true) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +### `queries()` + +```php +queries(array $query): static +``` + +Sets multiple endpoint-local query options. + +```php +return $this + ->endpoint() + ->queries(['active' => true, 'locale' => 'pt']) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +### `header()` + +```php +header(string $name, mixed $value): static +``` + +Sets one endpoint-local header. + +```php +return $this + ->endpoint() + ->header('X-Upload-Type', 'avatar') + ->body($stream) + ->post('/uploads') + ->raw(); +``` + +### `headers()` + +```php +headers(array $headers): static +``` + +Sets multiple endpoint-local headers. + +```php +return $this + ->endpoint() + ->headers(['X-Upload-Type' => 'avatar']) + ->body($stream) + ->post('/uploads') + ->raw(); +``` + +## Endpoint HTTP Methods + +Endpoint HTTP helpers execute the request immediately and return `Response`: + +```php +$endpoint = $this->endpoint(); + +$endpoint->get('/users'); +$endpoint->post('/users'); +$endpoint->put('/users/{id}', ['id' => $id]); +$endpoint->patch('/users/{id}', ['id' => $id]); +$endpoint->delete('/users/{id}', ['id' => $id]); +$endpoint->head('/users'); +$endpoint->options('/users'); +$endpoint->connect('/users'); +$endpoint->trace('/users'); +``` + +All helpers accept: + +```php +string $path +array $pathParams = [] +``` + +Use `query()`, `queries()`, `header()`, and `headers()` to configure request-local query parameters and headers before calling the HTTP helper. + +## Endpoint Cache Defaults + +SDK authors can configure endpoint-specific cache defaults on the endpoint builder: + +```php +return $this + ->endpoint() + ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +Endpoint cache defaults are immutable and apply only to that request. They require API-level cache configuration because the global cache setup provides the PSR-6 pool. + +## Resource Cache Overrides + +`withCache()` lets SDK users override cache behavior for one resource chain while keeping query, headers, body, and verbs inside `Endpoint`. + +```php +$users = $api + ->users() + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(30)) + ->all(); +``` + +This override is immutable and applies only to the chained resource instance. It requires API-level cache configuration because the global cache setup provides the PSR-6 pool. + +See [Cache](09-cache.md) for endpoint cache defaults, merge order, and the API-level cache requirement. + +## Navigation + +- Previous: [Resource Authoring](04-resource-authoring.md) +- Next: [Responses](06-responses.md) diff --git a/docs/06-responses.md b/docs/06-responses.md new file mode 100644 index 0000000..9730f7d --- /dev/null +++ b/docs/06-responses.md @@ -0,0 +1,171 @@ +# Responses + +Response mapping covers decoded data, raw PSR responses, entities, collections, envelopes, and hydration context. + +## `Response` + +`Response` wraps decoded response data and the raw PSR response. + +### `data()` + +```php +data(): mixed +``` + +Returns response data. + +When `responses()->json()` is enabled on the API, JSON bodies are decoded into arrays, empty bodies become `null`, and invalid JSON throws `JsonException`. + +When `responses()->xml()` is enabled, XML bodies are decoded into `SimpleXMLElement`, empty bodies become `null`, and invalid XML throws `RuntimeException`. + +When `responses()->custom()` is enabled, the configured callable receives the raw PSR response and returns the value used as `Response::data()`. + +When no response format is configured, this returns the raw response body string. + +```php +$data = $response->data(); +``` + +### `raw()` + +```php +raw(): ResponseInterface +``` + +Returns the raw PSR response. + +```php +$status = $response->raw()->getStatusCode(); +``` + +### `entity()` + +```php +entity(string $class, ?string $key = null): EntityInterface +``` + +Maps decoded response data to an entity class. + +```php +return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class, key: 'data'); +``` + +The class must implement `EntityInterface`. + +### `collection()` + +```php +collection(string $class, ?string $key = null): array +``` + +Maps list data to a plain array of entities. + +```php +return $this + ->endpoint() + ->get('/users') + ->collection(User::class, key: 'data'); +``` + +### `envelope()` + +```php +envelope(string $class): EnvelopeInterface +``` + +Maps the response to a custom envelope. + +```php +return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->envelope(UserEnvelope::class); +``` + +The class must implement `EnvelopeInterface`. + +## `EntityInterface` + +Entities used by response mapping must implement: + +```php +public static function fromArray(array $data, ?Context $context = null): static; +``` + +`fromArray()` is the mapping boundary for an entity. The package passes decoded response data to it; the SDK author decides how payload keys become constructor arguments, value objects, or derived values. + +## `EnvelopeInterface` + +Envelopes used by `Response::envelope()` must implement: + +```php +public static function fromResponse(Response $response, ?Context $context = null): static; +``` + +## `Context` + +`Context` carries SDK config into response mapping. + +SDK users do not fetch context from `Response`. The package passes context into entity and envelope hydration methods: + +```php +EntityInterface::fromArray(array $data, ?Context $context = null) +EnvelopeInterface::fromResponse(Response $response, ?Context $context = null) +``` + +### `config()` + +```php +config(): Config +``` + +Returns the SDK config available while hydrating entities or envelopes. + +```php +$timezone = $context?->config()->get('timezone'); +``` + +## `ErrorContext` + +`ErrorContext` is passed to configured error handlers. + +```php +$this->errors()->status(404, NotFoundException::class); +``` + +```php +$this->errors()->statuses([ + 401 => UnauthorizedException::class, + 404 => NotFoundException::class, +]); +``` + +```php +$this->errors()->status(404, function (ErrorContext $context): Throwable { + return new NotFoundException($context->response()->data()['message']); +}); +``` + +```php +$this->errors()->when(function (ErrorContext $context): ?Throwable { + if (($context->response()->data()['code'] ?? null) !== 'invalid_api_key') { + return null; + } + + return new InvalidApiKeyException($context->response()->data()['message']); +}); +``` + +It exposes: + +- `response(): Response` +- `apiContext(): Context` +- `statusCode(): int` + +## Navigation + +- Previous: [Resources](05-resources.md) +- Next: [Authentication](07-authentication.md) diff --git a/docs/07-authentication.md b/docs/07-authentication.md new file mode 100644 index 0000000..72f3102 --- /dev/null +++ b/docs/07-authentication.md @@ -0,0 +1,98 @@ +# Authentication + +Authentication is configured by the SDK author from the `Api` class. + +`auth()` returns an `AuthBuilder`. Every configured authentication rule is applied automatically to outgoing requests. + +## Common Helpers + +Use the built-in helpers for common API authentication styles: + +```php +$this->auth()->bearer($token); +``` + +```php +$this->auth()->basic($username, $password); +``` + +```php +$this->auth()->header('X-Api-Key', $apiKey); +``` + +```php +$this->auth()->query('appid', $apiKey); +``` + +```php +$this->auth()->wsse($username, $password); +$this->auth()->wsse($username, $password, hashAlgorithm: 'sha512'); +``` + +Calling another helper replaces the previously configured authentication. Use `chain()` when an SDK needs multiple authentication rules. + +## Query Authentication + +Use `query()` only when the API requires credentials in the URL. + +```php +$this->auth()->query('api_key', $apiKey); +``` + +For non-sensitive default query parameters such as `locale`, `units`, or `timezone`, use `defaultQueries()` instead: + +```php +$this->defaultQueries(['units' => 'metric']); +``` + +## HTTPlug Authentication Objects + +Use `chain()` when an SDK needs to compose specific [HTTPlug authentication implementations](https://docs.php-http.org/en/latest/message/authentication.html): + +```php +use Http\Message\Authentication\Bearer; +use Http\Message\Authentication\QueryParam; + +$this->auth()->chain( + new Bearer($token), + new QueryParam(['appid' => $apiKey]), +); +``` + +This is mostly useful when an SDK author already has an `Http\Message\Authentication` object or needs behavior provided by [`php-http/message`](https://docs.php-http.org/en/latest/message/index.html). + +## Conditional Authentication + +Use `conditional()` when authentication should only apply to matching requests. + +```php +use Http\Message\Authentication\Bearer; +use Http\Message\RequestMatcher\RequestMatcher; + +$this->auth()->conditional( + new RequestMatcher(path: '^/admin'), + new Bearer($adminToken), +); +``` + +`conditional()` uses PHP-HTTP's `RequestConditional` authentication internally. + +## Custom Authentication + +Use `custom()` for request-mutating authentication logic: + +```php +use Psr\Http\Message\RequestInterface; + +$this->auth()->custom(function (RequestInterface $request): RequestInterface { + return $request->withHeader('X-Custom-Auth', 'custom'); +}); +``` + +The callback receives the outgoing PSR request and must return a PSR request. +Returning anything else throws an `UnexpectedValueException`. + +## Navigation + +- Previous: [Responses](06-responses.md) +- Next: [HTTP Client](08-http-client.md) diff --git a/docs/08-http-client.md b/docs/08-http-client.md new file mode 100644 index 0000000..ae632ec --- /dev/null +++ b/docs/08-http-client.md @@ -0,0 +1,76 @@ +# HTTP Client + +The SDK uses a PSR-18 HTTP client to send requests and PSR-17 factories to create requests and streams. + +By default, the package uses PHP-HTTP discovery. When the `php-http/discovery` Composer plugin is enabled, missing PSR-18 and PSR-17 implementations can be installed automatically from the supported virtual packages required by this package. + +SDK authors can still provide concrete implementations explicitly when a concrete SDK should control its default HTTP stack. + +```php +use Http\Discovery\Psr17FactoryDiscovery; +use Http\Discovery\Psr18ClientDiscovery; + +$this + ->client(Psr18ClientDiscovery::find()) + ->requestFactory(Psr17FactoryDiscovery::findRequestFactory()) + ->streamFactory(Psr17FactoryDiscovery::findStreamFactory()); +``` + +## SDK Author Defaults + +SDK authors can configure the client and factories inside the API constructor when discovery should not choose them automatically. + +```php +use Nyholm\Psr7\Factory\Psr17Factory; +use ProgrammatorDev\Api\Api; +use Symfony\Component\HttpClient\Psr18Client; + +final class ExampleApi extends Api +{ + public function __construct() + { + $client = new Psr18Client(); + $psr17Factory = new Psr17Factory(); + + $this + ->client($client) + ->requestFactory($psr17Factory) + ->streamFactory($psr17Factory); + + $this->responses()->json(); + } +} +``` + +## SDK User Overrides + +SDK users can replace the client on a concrete API instance. + +```php +$api->setup()->client($client); +``` + +Factories can be replaced through the returned builder. + +```php +$api + ->setup() + ->client($client) + ->requestFactory($requestFactory) + ->streamFactory($streamFactory); +``` + +## Plugins + +HTTPlug plugins are not configured on the client builder. They are configured through `plugins()` so global middleware has one predictable place to live. + +```php +$api->setup()->plugins()->add($plugin, priority: 25); +``` + +See [Plugins](11-plugins.md) for plugin order and priority guidance. + +## Navigation + +- Previous: [Authentication](07-authentication.md) +- Next: [Cache](09-cache.md) diff --git a/docs/09-cache.md b/docs/09-cache.md new file mode 100644 index 0000000..51c2812 --- /dev/null +++ b/docs/09-cache.md @@ -0,0 +1,117 @@ +# Cache + +Cache support uses the [PHP-HTTP cache plugin](https://docs.php-http.org/en/latest/plugins/cache.html) with a PSR-6 cache pool. + +SDK authors can configure cache from the `Api` class: + +```php +$this + ->cache($pool) + ->defaultTtl(3600) + ->methods(['GET', 'HEAD']); +``` + +SDK users can also configure cache through `setup()`: + +```php +use Symfony\Component\Cache\Adapter\FilesystemAdapter; + +$api->setup()->cache(new FilesystemAdapter())->defaultTtl(3600); +``` + +When no TTL is configured, cached responses use a default TTL of 3600 seconds. + +## Options + +```php +$this->cache($pool)->defaultTtl(3600); +``` + +Sets the fallback cache TTL in seconds when the response does not provide cache directives. Use `null` to let the cache backend store as long as it can. + +```php +$this->cache($pool)->methods(['GET', 'HEAD']); +``` + +Sets which request methods can be cached. + +```php +$this->cache($pool)->responseCacheDirectives(['max-age']); +``` + +Sets the response cache directives respected by the cache plugin. + +## Cache Layers + +Cache has three layers: + +```text +API cache config < endpoint cache defaults < resource withCache override +``` + +The API cache config is required because it provides the PSR-6 pool. Endpoint defaults and resource overrides only adjust an already configured cache builder. + +```php +$api->setup()->cache($pool); +``` + +Use each layer for a different job: + +- API cache config: global cache pool and fallback defaults. +- Endpoint cache defaults: SDK-author intent for a specific endpoint. +- Resource `withCache()` override: SDK-user override for one resource chain. + +## Endpoint Defaults + +SDK authors can set endpoint-specific cache defaults on the endpoint builder: + +```php +public function live(): FixtureCollection +{ + return $this + ->endpoint() + ->cache(fn (CacheBuilder $cache) => $cache->defaultTtl(60)) + ->get('/fixtures/live') + ->envelope(FixtureCollection::class); +} +``` + +This is useful when the SDK author knows that one endpoint should behave differently from the global default. For example, realtime endpoints may default to a short TTL while stable lookup endpoints may default to a longer TTL. + +Endpoint defaults do not mutate the API cache builder and do not affect later requests. + +## Resource Overrides + +SDK users can override cache behavior for one resource chain with `withCache()`. The override wins over endpoint defaults, does not mutate the API cache builder, and does not affect later resource instances. + +```php +$fixtures = $api + ->fixtures() + ->withCache(fn (CacheBuilder $cache) => $cache->defaultTtl(30)) + ->live(); +``` + +`withCache()` is intentionally on the resource chain instead of every endpoint method argument. That keeps endpoint signatures focused on API-specific parameters while still giving SDK users a clear override path. + +## Missing Global Cache + +Endpoint defaults and resource overrides require global cache configuration: + +```php +$api->setup()->cache($pool); +``` + +If an endpoint or resource override is used without global cache configuration, the request fails because there is no PSR-6 pool to clone and adjust. + +## Internal Order + +The cache plugin runs at priority `20`, after authentication and before the logger plugin. + +When logging is configured, cache hit/miss/write events are logged through the cache plugin listener. + +See [Logging](10-logging.md) for cache log output. + +## Navigation + +- Previous: [HTTP Client](08-http-client.md) +- Next: [Logging](10-logging.md) diff --git a/docs/10-logging.md b/docs/10-logging.md new file mode 100644 index 0000000..56193a1 --- /dev/null +++ b/docs/10-logging.md @@ -0,0 +1,59 @@ +# Logging + +Logging uses the [PHP-HTTP logger plugin](https://docs.php-http.org/en/latest/plugins/logger.html) with a PSR-3 logger. + +SDK authors can configure logging from the `Api` class: + +```php +$this->logger($logger); +``` + +SDK users can also configure logging through `setup()`: + +```php +$api->setup()->logger($logger); +``` + +## Formatter + +The logger plugin can receive a custom formatter. + +```php +$this + ->logger($logger) + ->formatter($formatter); +``` + +The formatter is passed directly to the HTTPlug logger plugin. + +## Cache Logging + +When cache and logging are both configured, cache activity is also logged through the cache plugin listener. + +```php +$this + ->cache($pool) + ->defaultTtl(3600); + +$this->logger($logger); +``` + +The cache listener logs: + +- `HTTP cache hit:` when a cached response is reused. +- `HTTP response cached:` when a response is stored. + +The log context includes the cache key and, when available, the cache expiration date. + +## Internal Order + +The logger plugin runs at priority `10`, after cache. + +That means the cache plugin can serve cached responses before the request reaches later plugins. Cache-specific logging is handled by the cache listener instead of relying only on the logger plugin. + +See [Plugins](11-plugins.md) for the full internal plugin order. + +## Navigation + +- Previous: [Cache](09-cache.md) +- Next: [Plugins](11-plugins.md) diff --git a/docs/11-plugins.md b/docs/11-plugins.md new file mode 100644 index 0000000..baeee38 --- /dev/null +++ b/docs/11-plugins.md @@ -0,0 +1,81 @@ +# Plugins + +Plugins are [HTTPlug](https://httplug.io/) middleware applied to outgoing requests. + +See the [PHP-HTTP plugin documentation](https://docs.php-http.org/en/latest/plugins/index.html) for the underlying plugin system used here. + +HTTP clients and PSR-17 factories are configured through [HTTP Client](08-http-client.md). Plugins are configured separately so middleware order remains explicit. + +SDK authors can configure plugins from the `Api` class: + +```php +use Http\Client\Common\Plugin; + +$this->plugins()->add($plugin, priority: 16); +``` + +SDK users can also add plugins to a concrete API instance: + +```php +$api->setup()->plugins()->add($retryPlugin, priority: 20); +``` + +Higher priority plugins run earlier. Plugins with the same priority are preserved in insertion order. + +## Retry Plugin + +HTTPlug plugins can be configured directly. For example, the [retry plugin](https://docs.php-http.org/en/latest/plugins/retry.html) retries failed requests or retryable HTTP responses: + +```php +use Http\Client\Common\Plugin\RetryPlugin; + +$retryPlugin = new RetryPlugin([ + 'retries' => 2, +]); + +$this->plugins()->add($retryPlugin, priority: 25); +``` + +That priority runs retries after authentication is added to the request and before cache or logging. + +SDK users can apply the same plugin through `setup()`: + +```php +$api->setup()->plugins()->add($retryPlugin, priority: 25); +``` + +## Internal Plugin Order + +The package adds internal plugins with these priorities: + +| Priority | Plugin | Description | +| --- | --- | --- | +| `50` | [Content type](https://docs.php-http.org/en/latest/plugins/content-type.html) | Sets a `Content-Type` header when the request body makes it inferable and the header is not already set. | +| `40` | [Content length](https://docs.php-http.org/en/latest/plugins/content-length.html) | Sets request body length metadata before the request is sent. | +| `30` | [Authentication](https://docs.php-http.org/en/latest/plugins/authentication.html) | Applies credentials configured through `auth()`. | +| `20` | [Cache](https://docs.php-http.org/en/latest/plugins/cache.html) | Reads and writes cacheable responses through cache configured with `cache()`. Cache-specific logging is handled through cache listeners when logging is configured. | +| `10` | [Logger](https://docs.php-http.org/en/latest/plugins/logger.html) | Logs HTTP requests and responses through the PSR-3 logger configured with `logger()`. | + +Custom plugins use the same priority system, so they can run before, between, or after internal plugins. + +```php +$this->plugins()->add($plugin, priority: 60); // before content type +$this->plugins()->add($plugin, priority: 25); // between auth and cache +$this->plugins()->add($plugin, priority: 0); // after logger +``` + +## Same Priority + +Same-priority plugins do not overwrite each other. + +```php +$this->plugins()->add($first, priority: 16); +$this->plugins()->add($second, priority: 16); +``` + +The request reaches `$first` before `$second`. + +## Navigation + +- Previous: [Logging](10-logging.md) +- Next: [Hooks](12-hooks.md) diff --git a/docs/12-hooks.md b/docs/12-hooks.md new file mode 100644 index 0000000..b67eed8 --- /dev/null +++ b/docs/12-hooks.md @@ -0,0 +1,102 @@ +# Hooks + +Hooks let SDK authors and SDK users run callbacks around the HTTP request without exposing low-level request execution. + +SDK authors can configure hooks from an `Api` subclass: + +```php +use ProgrammatorDev\Api\Api; +use ProgrammatorDev\Api\Context\RequestContext; +use ProgrammatorDev\Api\Context\ResponseContext; + +final class ExampleApi extends Api +{ + public function __construct(string $apiKey) + { + $this->baseUrl('https://api.example.com'); + $this->responses()->json(); + + $this->hooks()->beforeRequest( + fn (RequestContext $context) => $context + ->request() + ->withHeader('X-Api-Key', $apiKey) + ); + + $this->hooks()->afterResponse( + fn (ResponseContext $context) => $context + ->response() + ->withoutHeader('X-Debug-Trace') + ); + } +} +``` + +## Before Request + +`beforeRequest()` runs after the PSR-7 request is created and before it is sent. + +```php +$this->hooks()->beforeRequest(function (RequestContext $context) { + return $context->request()->withHeader('X-Tenant', $context->apiContext()->config()->get('tenant')); +}); +``` + +Return a `RequestInterface` to replace the request. Return `null` to leave it unchanged. Any other return value throws. + +## After Response + +`afterResponse()` runs after the HTTP response is received and before response decoding, response wrapping, and error handling. + +```php +$this->hooks()->afterResponse(function (ResponseContext $context) { + return $context->response()->withoutHeader('X-Debug-Trace'); +}); +``` + +Return a `ResponseInterface` to replace the response. Return `null` to leave it unchanged. Any other return value throws. + +## Priority + +Higher priority hooks run earlier. Hooks with the same priority run in insertion order. + +```php +$this->hooks()->beforeRequest($first, priority: 20); +$this->hooks()->beforeRequest($second, priority: 20); +$this->hooks()->beforeRequest($later, priority: 10); +``` + +The request reaches `$first`, then `$second`, then `$later`. + +## Context + +`RequestContext` exposes: + +- `request()` +- `apiContext()` + +`ResponseContext` exposes: + +- `request()` +- `response()` +- `apiContext()` + +The shared `Context` gives hooks access to SDK config without injecting the full API instance. + +## Order + +The current request flow is: + +```text +create request +beforeRequest hooks +send request +afterResponse hooks +decode response +create Response +errors +return Response +``` + +## Navigation + +- Previous: [Plugins](11-plugins.md) diff --git a/src/Api.php b/src/Api.php index 54bbdba..5e43ca9 100644 --- a/src/Api.php +++ b/src/Api.php @@ -2,32 +2,35 @@ namespace ProgrammatorDev\Api; -use Http\Client\Common\Plugin\AuthenticationPlugin; -use Http\Client\Common\Plugin\CachePlugin; -use Http\Client\Common\Plugin\ContentLengthPlugin; -use Http\Client\Common\Plugin\ContentTypePlugin; -use Http\Client\Common\Plugin\LoggerPlugin; -use Http\Message\Authentication; +use ProgrammatorDev\Api\Builder\AuthBuilder; use ProgrammatorDev\Api\Builder\CacheBuilder; use ProgrammatorDev\Api\Builder\ClientBuilder; -use ProgrammatorDev\Api\Builder\Listener\CacheLoggerListener; +use ProgrammatorDev\Api\Builder\ErrorBuilder; +use ProgrammatorDev\Api\Builder\HookBuilder; use ProgrammatorDev\Api\Builder\LoggerBuilder; -use ProgrammatorDev\Api\Event\PostRequestEvent; -use ProgrammatorDev\Api\Event\PreRequestEvent; -use ProgrammatorDev\Api\Event\ResponseContentsEvent; -use ProgrammatorDev\Api\Helper\StringHelper; -use Psr\Http\Client\ClientExceptionInterface as ClientException; -use Psr\Http\Message\RequestInterface; +use ProgrammatorDev\Api\Builder\PluginBuilder; +use ProgrammatorDev\Api\Builder\ResponseBuilder; +use ProgrammatorDev\Api\Config\Config; +use ProgrammatorDev\Api\Http\Transport; +use ProgrammatorDev\Api\Request\PipelineOptions; +use ProgrammatorDev\Api\Request\RequestOptions; +use ProgrammatorDev\Api\Response\Response; +use ProgrammatorDev\Api\Response\ResponseDecoder; +use Psr\Http\Client\ClientExceptionInterface; +use Psr\Http\Client\ClientInterface; +use Psr\Cache\CacheItemPoolInterface; use Psr\Http\Message\StreamInterface; -use Symfony\Component\EventDispatcher\EventDispatcher; +use Psr\Log\LoggerInterface; -class Api +abstract class Api { private ?string $baseUrl = null; - private array $queryDefaults = []; + private array $defaultQueries = []; - private array $headerDefaults = []; + private array $defaultHeaders = []; + + private Config $config; private ClientBuilder $clientBuilder; @@ -35,278 +38,192 @@ class Api private ?LoggerBuilder $loggerBuilder = null; - private ?Authentication $authentication = null; + private AuthBuilder $authBuilder; + + private PluginBuilder $pluginBuilder; + + private ResponseBuilder $responseBuilder; - private EventDispatcher $eventDispatcher; + private ErrorBuilder $errorBuilder; + + private HookBuilder $hookBuilder; public function __construct() { - $this->clientBuilder ??= new ClientBuilder(); - $this->eventDispatcher = new EventDispatcher(); + $this->config = new Config(); + $this->clientBuilder = new ClientBuilder(); + $this->authBuilder = new AuthBuilder(); + $this->pluginBuilder = new PluginBuilder(); + $this->responseBuilder = new ResponseBuilder(); + $this->errorBuilder = new ErrorBuilder(); + $this->hookBuilder = new HookBuilder(); } /** - * @throws ClientException + * @throws ClientExceptionInterface + * @throws \JsonException + * @throws \RuntimeException + * @throws \Throwable */ - public function request( + public function send( string $method, string $path, + array $pathParams = [], array $query = [], array $headers = [], - string|StreamInterface $body = null - ): mixed - { - $this->configurePlugins(); - - if (!empty($this->queryDefaults)) { - $query = array_merge($this->queryDefaults, $query); - } - - if (!empty($this->headerDefaults)) { - $headers = array_merge($this->headerDefaults, $headers); - } - - $url = $this->buildUrl($path, $query); - $request = $this->createRequest($method, $url, $headers, $body); - - // pre request listener - $request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest(); - - // request - $response = $this->clientBuilder->getClient()->sendRequest($request); - - // post request listener - $response = $this->eventDispatcher->dispatch(new PostRequestEvent($request, $response))->getResponse(); - - // always rewind the body contents in case it was used in the PostRequestEvent - // otherwise it would return an empty string - $response->getBody()->rewind(); - $contents = $response->getBody()->getContents(); - - // response contents listener - return $this->eventDispatcher->dispatch(new ResponseContentsEvent($contents))->getContents(); + string|StreamInterface|null $body = null + ): Response + { + $options = (new RequestOptions()) + ->withQueries($query) + ->withHeaders($headers) + ->withBody($body); + + return $this->runtime()->send( + method: $method, + path: $path, + pathParams: $pathParams, + requestOptions: $options, + pipelineOptions: new PipelineOptions() + ); } - private function configurePlugins(): void + public function setup(): Setup { - // https://docs.php-http.org/en/latest/plugins/content-type.html - $this->clientBuilder->addPlugin( - plugin: new ContentTypePlugin(), - priority: 40 - ); - - // https://docs.php-http.org/en/latest/plugins/content-length.html - $this->clientBuilder->addPlugin( - plugin: new ContentLengthPlugin(), - priority: 32 + return new Setup( + // Keep setup helpers protected on Api while exposing them through + // one explicit SDK-user setup surface. + fn(string $method, array $arguments): mixed => $this->{$method}(...$arguments) ); - - // https://docs.php-http.org/en/latest/message/authentication.html - if ($this->authentication) { - $this->clientBuilder->addPlugin( - plugin: new AuthenticationPlugin($this->authentication), - priority: 24 - ); - } - - // https://docs.php-http.org/en/latest/plugins/cache.html - if ($this->cacheBuilder) { - $cacheOptions = [ - 'default_ttl' => $this->cacheBuilder->getTtl(), - 'methods' => $this->cacheBuilder->getMethods(), - 'respect_response_cache_directives' => $this->cacheBuilder->getResponseCacheDirectives(), - 'cache_listeners' => [] - ]; - - if ($this->loggerBuilder) { - $cacheOptions['cache_listeners'][] = new CacheLoggerListener($this->loggerBuilder); - } - - $this->clientBuilder->addPlugin( - plugin: new CachePlugin( - $this->cacheBuilder->getPool(), - $this->clientBuilder->getStreamFactory(), - $cacheOptions - ), - priority: 16 - ); - } - - // https://docs.php-http.org/en/latest/plugins/logger.html - if ($this->loggerBuilder) { - $this->clientBuilder->addPlugin( - plugin: new LoggerPlugin( - $this->loggerBuilder->getLogger(), - $this->loggerBuilder->getFormatter() - ), - priority: 8 - ); - } } - public function getBaseUrl(): ?string + /** + * @template T of Resource + * @param class-string $class + * @return T + */ + protected function resource(string $class): Resource { - return $this->baseUrl; + return new $class($this->runtime()); } - public function setBaseUrl(?string $baseUrl): self + protected function baseUrl(?string $baseUrl): static { $this->baseUrl = $baseUrl; return $this; } - public function getQueryDefault(string $name): mixed + protected function defaultQuery(string $name, mixed $value): static { - return $this->queryDefaults[$name] ?? null; - } - - public function addQueryDefault(string $name, mixed $value): self - { - $this->queryDefaults[$name] = $value; + $this->defaultQueries[$name] = $value; return $this; } - public function removeQueryDefault(string $name): self + protected function defaultQueries(array $query): static { - unset($this->queryDefaults[$name]); + $this->defaultQueries = array_merge($this->defaultQueries, $query); return $this; } - public function getHeaderDefault(string $name): mixed - { - return $this->headerDefaults[$name] ?? null; - } - - public function addHeaderDefault(string $name, mixed $value): self + protected function defaultHeader(string $name, mixed $value): static { - $this->headerDefaults[$name] = $value; + $this->defaultHeaders[$name] = $value; return $this; } - public function removeHeaderDefault(string $name): self + protected function defaultHeaders(array $headers): static { - unset($this->headerDefaults[$name]); + $this->defaultHeaders = array_merge($this->defaultHeaders, $headers); return $this; } - public function getClientBuilder(): ?ClientBuilder + protected function responses(): ResponseBuilder { - return $this->clientBuilder; + return $this->responseBuilder; } - public function setClientBuilder(ClientBuilder $clientBuilder): self + protected function errors(): ErrorBuilder { - $this->clientBuilder = $clientBuilder; - - return $this; + return $this->errorBuilder; } - public function getCacheBuilder(): ?CacheBuilder + protected function auth(): AuthBuilder { - return $this->cacheBuilder; + return $this->authBuilder; } - public function setCacheBuilder(?CacheBuilder $cacheBuilder): self + protected function hooks(): HookBuilder { - $this->cacheBuilder = $cacheBuilder; - - return $this; + return $this->hookBuilder; } - public function getLoggerBuilder(): ?LoggerBuilder + protected function plugins(): PluginBuilder { - return $this->loggerBuilder; - } - - public function setLoggerBuilder(?LoggerBuilder $loggerBuilder): self - { - $this->loggerBuilder = $loggerBuilder; - - return $this; - } - - public function getAuthentication(): ?Authentication - { - return $this->authentication; - } - - public function setAuthentication(?Authentication $authentication): self - { - $this->authentication = $authentication; - - return $this; + return $this->pluginBuilder; } - public function addPreRequestListener(callable $listener, int $priority = 0): self + protected function cache(CacheItemPoolInterface $pool): CacheBuilder { - $this->eventDispatcher->addListener(PreRequestEvent::class, $listener, $priority); + $this->cacheBuilder = new CacheBuilder($pool); - return $this; + return $this->cacheBuilder; } - public function addPostRequestListener(callable $listener, int $priority = 0): self + protected function client(ClientInterface $client): ClientBuilder { - $this->eventDispatcher->addListener(PostRequestEvent::class, $listener, $priority); + $this->clientBuilder->client($client); - return $this; + return $this->clientBuilder; } - public function addResponseContentsListener(callable $listener, int $priority = 0): self + protected function logger(LoggerInterface $logger): LoggerBuilder { - $this->eventDispatcher->addListener(ResponseContentsEvent::class, $listener, $priority); + $this->loggerBuilder = new LoggerBuilder($logger); - return $this; + return $this->loggerBuilder; } - public function buildPath(string $path, array $parameters): string + public function config(array $values = [], array $defaults = []): Config { - foreach ($parameters as $parameter => $value) { - $path = str_replace( - sprintf('{%s}', $parameter), - $value, - $path - ); + if ($defaults !== []) { + $this->config->merge($defaults); } - return $path; - } - - private function buildUrl(string $path, array $query = []): string - { - $appendQuery = http_build_query($query); - - if (StringHelper::isUrl($path)) { - return append_query_string($path, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); + if ($values !== []) { + $this->config->merge($values); } - $url = StringHelper::reduceDuplicateSlashes($this->baseUrl . $path); - return append_query_string($url, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); + return $this->config; } - private function createRequest( - string $method, - string $url, - array $headers = [], - string|StreamInterface $body = null - ): RequestInterface + private function runtime(): Runtime { - $request = $this->clientBuilder->getRequestFactory()->createRequest($method, $url); - - foreach ($headers as $key => $value) { - $request = $request->withHeader($key, $value); - } + // Build transport at send time so resources created before later + // setup() changes still use the latest API configuration. + $transport = fn(): Transport => new Transport( + clientBuilder: $this->clientBuilder, + authBuilder: $this->authBuilder, + pluginBuilder: $this->pluginBuilder, + hookBuilder: $this->hookBuilder, + cacheBuilder: $this->cacheBuilder, + loggerBuilder: $this->loggerBuilder, + baseUrl: $this->baseUrl, + defaultQueries: $this->defaultQueries, + defaultHeaders: $this->defaultHeaders + ); - if ($body !== null && $body !== '') { - $request = $request->withBody( - is_string($body) ? $this->clientBuilder->getStreamFactory()->createStream($body) : $body - ); - } + $responseDecoder = new ResponseDecoder($this->responseBuilder); - return $request; + return new Runtime( + config: $this->config, + transport: $transport, + responseDecoder: $responseDecoder, + errorBuilder: $this->errorBuilder + ); } -} \ No newline at end of file +} diff --git a/src/Authentication/CallbackAuthentication.php b/src/Authentication/CallbackAuthentication.php new file mode 100644 index 0000000..9295877 --- /dev/null +++ b/src/Authentication/CallbackAuthentication.php @@ -0,0 +1,27 @@ +callback)($request); + + if (! $authenticatedRequest instanceof RequestInterface) { + throw new \UnexpectedValueException('Custom authentication callback must return a PSR-7 request.'); + } + + return $authenticatedRequest; + } +} diff --git a/src/Builder/AuthBuilder.php b/src/Builder/AuthBuilder.php new file mode 100644 index 0000000..f87203b --- /dev/null +++ b/src/Builder/AuthBuilder.php @@ -0,0 +1,77 @@ +use(new Bearer($token)); + } + + public function basic(string $username, string $password): self + { + return $this->use(new BasicAuth($username, $password)); + } + + public function header(string $name, string|array $value): self + { + return $this->use(new Header($name, $value)); + } + + public function query(string $name, mixed $value): self + { + return $this->use(new QueryParam([$name => $value])); + } + + public function wsse(string $username, string $password, string $hashAlgorithm = 'sha1'): self + { + return $this->use(new Wsse($username, $password, $hashAlgorithm)); + } + + public function conditional(RequestMatcher $matcher, Authentication $authentication): self + { + return $this->use(new RequestConditional($matcher, $authentication)); + } + + public function chain(Authentication ...$authentications): self + { + return $this->use(new Chain($authentications)); + } + + /** + * @param callable(RequestInterface): RequestInterface $callback + */ + public function custom(callable $callback): self + { + return $this->use(new CallbackAuthentication($callback)); + } + + public function use(Authentication $authentication): self + { + // Authentication is intentionally replaced, not appended. Use chain() + // when multiple authentication strategies must run on the same request. + $this->authentication = $authentication; + + return $this; + } + + public function getAuthentication(): ?Authentication + { + return $this->authentication; + } +} diff --git a/src/Builder/CacheBuilder.php b/src/Builder/CacheBuilder.php index ce83039..7e2fc2d 100644 --- a/src/Builder/CacheBuilder.php +++ b/src/Builder/CacheBuilder.php @@ -2,14 +2,14 @@ namespace ProgrammatorDev\Api\Builder; -use ProgrammatorDev\Api\Method; +use ProgrammatorDev\Api\Http\Method; use Psr\Cache\CacheItemPoolInterface; class CacheBuilder { public function __construct( private CacheItemPoolInterface $pool, - private ?int $ttl = 60, + private ?int $defaultTtl = 3600, private array $methods = [Method::GET, Method::HEAD], private array $responseCacheDirectives = ['max-age'] ) {} @@ -19,21 +19,21 @@ public function getPool(): CacheItemPoolInterface return $this->pool; } - public function setPool(CacheItemPoolInterface $pool): self + public function pool(CacheItemPoolInterface $pool): self { $this->pool = $pool; return $this; } - public function getTtl(): ?int + public function getDefaultTtl(): ?int { - return $this->ttl; + return $this->defaultTtl; } - public function setTtl(?int $ttl): self + public function defaultTtl(?int $defaultTtl): self { - $this->ttl = $ttl; + $this->defaultTtl = $defaultTtl; return $this; } @@ -43,7 +43,7 @@ public function getMethods(): array return $this->methods; } - public function setMethods(array $methods): self + public function methods(array $methods): self { $this->methods = $methods; @@ -55,10 +55,10 @@ public function getResponseCacheDirectives(): array return $this->responseCacheDirectives; } - public function setResponseCacheDirectives(array $responseCacheDirectives): self + public function responseCacheDirectives(array $responseCacheDirectives): self { $this->responseCacheDirectives = $responseCacheDirectives; return $this; } -} \ No newline at end of file +} diff --git a/src/Builder/ClientBuilder.php b/src/Builder/ClientBuilder.php index 9855ffd..bc5bd6e 100644 --- a/src/Builder/ClientBuilder.php +++ b/src/Builder/ClientBuilder.php @@ -3,35 +3,32 @@ namespace ProgrammatorDev\Api\Builder; use Http\Client\Common\HttpMethodsClient; -use Http\Client\Common\Plugin; use Http\Client\Common\PluginClientFactory; use Http\Discovery\Psr17FactoryDiscovery; use Http\Discovery\Psr18ClientDiscovery; -use ProgrammatorDev\Api\Exception\PluginException; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; class ClientBuilder { - /** @var Plugin[] */ - private array $plugins = []; - public function __construct( private ?ClientInterface $client = null, private ?RequestFactoryInterface $requestFactory = null, private ?StreamFactoryInterface $streamFactory = null - ) - { + ) { $this->client ??= Psr18ClientDiscovery::find(); $this->requestFactory ??= Psr17FactoryDiscovery::findRequestFactory(); $this->streamFactory ??= Psr17FactoryDiscovery::findStreamFactory(); } - public function getClient(): HttpMethodsClient + /** + * @param list<\Http\Client\Common\Plugin> $plugins + */ + public function getClient(array $plugins = []): HttpMethodsClient { $pluginClientFactory = new PluginClientFactory(); - $client = $pluginClientFactory->createClient($this->client, $this->plugins); + $client = $pluginClientFactory->createClient($this->client, $plugins); return new HttpMethodsClient( $client, @@ -40,10 +37,10 @@ public function getClient(): HttpMethodsClient ); } - public function setClient(ClientInterface $client): self + public function client(ClientInterface $client): self { $this->client = $client; - + return $this; } @@ -52,7 +49,7 @@ public function getRequestFactory(): RequestFactoryInterface return $this->requestFactory; } - public function setRequestFactory(RequestFactoryInterface $requestFactory): self + public function requestFactory(RequestFactoryInterface $requestFactory): self { $this->requestFactory = $requestFactory; @@ -64,24 +61,10 @@ public function getStreamFactory(): StreamFactoryInterface return $this->streamFactory; } - public function setStreamFactory(StreamFactoryInterface $streamFactory): self + public function streamFactory(StreamFactoryInterface $streamFactory): self { $this->streamFactory = $streamFactory; return $this; } - - public function addPlugin(Plugin $plugin, int $priority): self - { - $this->plugins[$priority] = $plugin; - // sort plugins by priority (key) in descending order - krsort($this->plugins); - - return $this; - } - - public function getPlugins(): array - { - return $this->plugins; - } -} \ No newline at end of file +} diff --git a/src/Builder/ErrorBuilder.php b/src/Builder/ErrorBuilder.php new file mode 100644 index 0000000..902671c --- /dev/null +++ b/src/Builder/ErrorBuilder.php @@ -0,0 +1,87 @@ +|callable(ErrorContext): \Throwable> */ + private array $statusHandlers = []; + + /** @var array */ + private array $handlers = []; + + /** + * @param class-string<\Throwable>|callable(ErrorContext): \Throwable $handler + */ + public function status(int $statusCode, string|callable $handler): self + { + if (is_string($handler) && ! is_a($handler, \Throwable::class, true)) { + throw new \InvalidArgumentException(sprintf( + 'Error handler for status %d must be a Throwable class or callable.', + $statusCode + )); + } + + $this->statusHandlers[$statusCode] = $handler; + + return $this; + } + + /** + * @param array|callable(ErrorContext): \Throwable> $handlers + */ + public function statuses(array $handlers): self + { + foreach ($handlers as $statusCode => $handler) { + $this->status($statusCode, $handler); + } + + return $this; + } + + /** + * @param callable(ErrorContext): ?\Throwable $handler + */ + public function when(callable $handler): self + { + $this->handlers[] = $handler; + + return $this; + } + + /** + * @throws \Throwable + */ + public function throwIfMatched(ErrorContext $context): void + { + $handler = $this->statusHandlers[$context->statusCode()] ?? null; + + if (is_string($handler)) { + throw new $handler(); + } + + if ($handler !== null) { + $throwable = $handler($context); + + if (! $throwable instanceof \Throwable) { + throw new \UnexpectedValueException('Status error handler must return a Throwable.'); + } + + throw $throwable; + } + + foreach ($this->handlers as $handler) { + $throwable = $handler($context); + + if ($throwable instanceof \Throwable) { + throw $throwable; + } + + if ($throwable !== null) { + throw new \UnexpectedValueException('Error handler must return a Throwable or null.'); + } + } + } +} diff --git a/src/Builder/HookBuilder.php b/src/Builder/HookBuilder.php new file mode 100644 index 0000000..5bf94d2 --- /dev/null +++ b/src/Builder/HookBuilder.php @@ -0,0 +1,109 @@ +> */ + private array $beforeRequestHooks = []; + + /** @var array> */ + private array $afterResponseHooks = []; + + /** + * @param callable(RequestContext): (RequestInterface|null) $hook + */ + public function beforeRequest(callable $hook, int $priority = 0): self + { + $this->beforeRequestHooks[$priority] ??= []; + $this->beforeRequestHooks[$priority][] = $hook; + + return $this; + } + + /** + * @param callable(ResponseContext): (ResponseInterface|null) $hook + */ + public function afterResponse(callable $hook, int $priority = 0): self + { + $this->afterResponseHooks[$priority] ??= []; + $this->afterResponseHooks[$priority][] = $hook; + + return $this; + } + + /** + * @throws UnexpectedValueException + */ + public function applyBeforeRequestHooks(RequestContext $context): RequestInterface + { + $request = $context->request(); + + foreach ($this->sort($this->beforeRequestHooks) as $hook) { + // Each hook receives the request produced by the previous hook so + // small request mutations can compose without sharing mutable state. + $replacement = $hook(new RequestContext($request, $context->apiContext())); + + if ($replacement instanceof RequestInterface) { + $request = $replacement; + + continue; + } + + if ($replacement !== null) { + throw new UnexpectedValueException('Before request hooks must return a RequestInterface instance or null.'); + } + } + + return $request; + } + + /** + * @throws UnexpectedValueException + */ + public function applyAfterResponseHooks(ResponseContext $context): ResponseInterface + { + $response = $context->response(); + + foreach ($this->sort($this->afterResponseHooks) as $hook) { + // Later hooks see the response returned by earlier hooks, mirroring + // the before-request pipeline while keeping PSR responses immutable. + $replacement = $hook(new ResponseContext($context->request(), $response, $context->apiContext())); + + if ($replacement instanceof ResponseInterface) { + $response = $replacement; + + continue; + } + + if ($replacement !== null) { + throw new UnexpectedValueException('After response hooks must return a ResponseInterface instance or null.'); + } + } + + return $response; + } + + /** + * @param array> $hooks + * @return list + */ + private function sort(array $hooks): array + { + if ($hooks === []) { + return []; + } + + krsort($hooks); + + // Higher priority runs first; hooks with the same priority keep their + // registration order because the per-priority lists are never re-sorted. + return array_values(array_merge(...array_values($hooks))); + } +} diff --git a/src/Builder/Listener/CacheLoggerListener.php b/src/Builder/Listener/CacheLoggerListener.php index 74a3180..a936de3 100644 --- a/src/Builder/Listener/CacheLoggerListener.php +++ b/src/Builder/Listener/CacheLoggerListener.php @@ -22,33 +22,37 @@ public function onCacheResponse( $logger = $this->loggerBuilder->getLogger(); $formatter = $this->loggerBuilder->getFormatter(); - // if response is a cache hit if ($fromCache) { /** @var $cacheItem CacheItemInterface */ $logger->info( - sprintf("Cache hit:\n%s", $formatter->formatRequest($request)), + sprintf("HTTP cache hit:\n%s", $formatter->formatRequest($request)), [ - 'expires' => $cacheItem->get()['expiresAt'], - 'key' => $cacheItem->getKey() + 'cache_expires_at' => $this->getExpiresAt($cacheItem), + 'cache_key' => $cacheItem->getKey() ] ); } - // if response is a cache miss (and was cached) else if ($cacheItem instanceof CacheItemInterface) { - // handle future deprecation $formattedResponse = method_exists($formatter, 'formatResponseForRequest') ? $formatter->formatResponseForRequest($response, $request) : $formatter->formatResponse($response); $logger->info( - sprintf("Cached response:\n%s", $formattedResponse), + sprintf("HTTP response cached:\n%s", $formattedResponse), [ - 'expires' => $cacheItem->get()['expiresAt'], - 'key' => $cacheItem->getKey() + 'cache_expires_at' => $this->getExpiresAt($cacheItem), + 'cache_key' => $cacheItem->getKey() ] ); } return $response; } -} \ No newline at end of file + + private function getExpiresAt(CacheItemInterface $cacheItem): mixed + { + $data = $cacheItem->get(); + + return is_array($data) ? $data['expiresAt'] ?? null : null; + } +} diff --git a/src/Builder/LoggerBuilder.php b/src/Builder/LoggerBuilder.php index 863ed5f..6d679f3 100644 --- a/src/Builder/LoggerBuilder.php +++ b/src/Builder/LoggerBuilder.php @@ -12,8 +12,7 @@ class LoggerBuilder public function __construct( private LoggerInterface $logger, ?Formatter $formatter = null - ) - { + ) { $this->formatter = $formatter ?: new Formatter\SimpleFormatter(); } @@ -22,7 +21,7 @@ public function getLogger(): LoggerInterface return $this->logger; } - public function setLogger(LoggerInterface $logger): self + public function logger(LoggerInterface $logger): self { $this->logger = $logger; @@ -34,10 +33,10 @@ public function getFormatter(): Formatter return $this->formatter; } - public function setFormatter(Formatter $formatter): self + public function formatter(Formatter $formatter): self { $this->formatter = $formatter; return $this; } -} \ No newline at end of file +} diff --git a/src/Builder/PluginBuilder.php b/src/Builder/PluginBuilder.php new file mode 100644 index 0000000..b973ece --- /dev/null +++ b/src/Builder/PluginBuilder.php @@ -0,0 +1,55 @@ +> */ + private array $plugins = []; + + public function add(Plugin $plugin, int $priority = 0): self + { + $this->plugins[$priority] ??= []; + $this->plugins[$priority][] = $plugin; + + return $this; + } + + public function merge(self $builder): self + { + foreach ($builder->getEntries() as $priority => $plugins) { + foreach ($plugins as $plugin) { + $this->add($plugin, $priority); + } + } + + return $this; + } + + /** + * @return array> + */ + public function getEntries(): array + { + return $this->plugins; + } + + /** + * @return list + */ + public function getPlugins(): array + { + if ($this->plugins === []) { + return []; + } + + $plugins = $this->plugins; + krsort($plugins); + + // Higher priority plugins are placed earlier in the HTTPlug chain; equal + // priority preserves registration order inside each bucket. + return array_values(array_merge(...array_values($plugins))); + } +} diff --git a/src/Builder/ResponseBuilder.php b/src/Builder/ResponseBuilder.php new file mode 100644 index 0000000..66cb146 --- /dev/null +++ b/src/Builder/ResponseBuilder.php @@ -0,0 +1,62 @@ +format = ResponseFormat::Raw; + $this->customDecoder = null; + + return $this; + } + + public function json(): self + { + $this->format = ResponseFormat::Json; + $this->customDecoder = null; + + return $this; + } + + public function xml(): self + { + $this->format = ResponseFormat::Xml; + $this->customDecoder = null; + + return $this; + } + + /** + * @param callable(ResponseInterface): mixed $decoder + */ + public function custom(callable $decoder): self + { + $this->format = ResponseFormat::Custom; + $this->customDecoder = $decoder; + + return $this; + } + + public function getFormat(): ResponseFormat + { + return $this->format; + } + + /** + * @return null|callable(ResponseInterface): mixed + */ + public function getCustomDecoder(): ?callable + { + return $this->customDecoder; + } +} diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..feae06b --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,58 @@ +values; + } + + public function only(string ...$keys): array + { + $values = []; + + foreach ($keys as $key) { + if ($this->has($key)) { + $values[$key] = $this->values[$key]; + } + } + + return $values; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->values); + } + + public function get(string $key, mixed $default = null): mixed + { + if (!$this->has($key)) { + return $default; + } + + return $this->values[$key]; + } + + public function set(string $key, mixed $value): self + { + $this->values[$key] = $value; + + return $this; + } + + public function merge(array $values): self + { + foreach ($values as $key => $value) { + $this->set($key, $value); + } + + return $this; + } +} diff --git a/src/Context/Context.php b/src/Context/Context.php new file mode 100644 index 0000000..8241eb0 --- /dev/null +++ b/src/Context/Context.php @@ -0,0 +1,17 @@ +config; + } +} diff --git a/src/Context/ErrorContext.php b/src/Context/ErrorContext.php new file mode 100644 index 0000000..2f83e34 --- /dev/null +++ b/src/Context/ErrorContext.php @@ -0,0 +1,28 @@ +response; + } + + public function apiContext(): Context + { + return $this->context; + } + + public function statusCode(): int + { + return $this->response->raw()->getStatusCode(); + } +} diff --git a/src/Context/RequestContext.php b/src/Context/RequestContext.php new file mode 100644 index 0000000..a95c99f --- /dev/null +++ b/src/Context/RequestContext.php @@ -0,0 +1,23 @@ +request; + } + + public function apiContext(): Context + { + return $this->context; + } +} diff --git a/src/Context/ResponseContext.php b/src/Context/ResponseContext.php new file mode 100644 index 0000000..a1d2e9b --- /dev/null +++ b/src/Context/ResponseContext.php @@ -0,0 +1,30 @@ +request; + } + + public function response(): ResponseInterface + { + return $this->response; + } + + public function apiContext(): Context + { + return $this->context; + } +} diff --git a/src/Contract/EntityInterface.php b/src/Contract/EntityInterface.php new file mode 100644 index 0000000..4181025 --- /dev/null +++ b/src/Contract/EntityInterface.php @@ -0,0 +1,10 @@ +options = new RequestOptions(); + } + + /** + * @param callable(\ProgrammatorDev\Api\Builder\CacheBuilder): mixed $configure + */ + public function cache(callable $configure): static + { + return $this->withPipelineOptions( + $this->pipelineOptions->withDefault(PipelineOption::CACHE, $configure) + ); + } + + /** + * @throws \JsonException + */ + public function json(array $data): static + { + return $this + ->header('Content-Type', 'application/json') + ->body(json_encode($data, JSON_THROW_ON_ERROR)); + } + + public function form(array $data): static + { + return $this + ->header('Content-Type', 'application/x-www-form-urlencoded') + ->body(http_build_query($data)); + } + + /** + * @throws \InvalidArgumentException + */ + public function body(mixed $body): static + { + if (is_array($body)) { + throw new \InvalidArgumentException('Use json() or form() to send array request data.'); + } + + if (!$body instanceof StreamInterface && !is_string($body) && $body !== null) { + throw new \InvalidArgumentException('Request body must be a string, stream, or null.'); + } + + return $this->withOptions($this->options->withBody($body)); + } + + public function query(string $name, mixed $value): static + { + return $this->withOptions($this->options->withQuery($name, $value)); + } + + public function queries(array $query): static + { + return $this->withOptions($this->options->withQueries($query)); + } + + public function header(string $name, mixed $value): static + { + return $this->withOptions($this->options->withHeader($name, $value)); + } + + public function headers(array $headers): static + { + return $this->withOptions($this->options->withHeaders($headers)); + } + + /** + * @throws \Throwable + */ + public function get(string $path, array $pathParams = []): Response + { + return $this->send(Method::GET, $path, $pathParams); + } + + /** + * @throws \Throwable + */ + public function post(string $path, array $pathParams = []): Response + { + return $this->send(Method::POST, $path, $pathParams); + } + + /** + * @throws \Throwable + */ + public function put(string $path, array $pathParams = []): Response + { + return $this->send(Method::PUT, $path, $pathParams); + } + + /** + * @throws \Throwable + */ + public function patch(string $path, array $pathParams = []): Response + { + return $this->send(Method::PATCH, $path, $pathParams); + } + + /** + * @throws \Throwable + */ + public function delete(string $path, array $pathParams = []): Response + { + return $this->send(Method::DELETE, $path, $pathParams); + } + + /** + * @throws \Throwable + */ + public function head(string $path, array $pathParams = []): Response + { + return $this->send(Method::HEAD, $path, $pathParams); + } + + /** + * @throws \Throwable + */ + public function options(string $path, array $pathParams = []): Response + { + return $this->send(Method::OPTIONS, $path, $pathParams); + } + + /** + * @throws \Throwable + */ + public function connect(string $path, array $pathParams = []): Response + { + return $this->send(Method::CONNECT, $path, $pathParams); + } + + /** + * @throws \Throwable + */ + public function trace(string $path, array $pathParams = []): Response + { + return $this->send(Method::TRACE, $path, $pathParams); + } + + /** + * @throws \Throwable + */ + private function send(string $method, string $path, array $pathParams = []): Response + { + return $this->runtime->send( + method: $method, + path: $path, + pathParams: $pathParams, + requestOptions: $this->options, + pipelineOptions: $this->pipelineOptions + ); + } + + private function withOptions(RequestOptions $options): static + { + $clone = clone $this; + $clone->options = $options; + + return $clone; + } + + private function withPipelineOptions(PipelineOptions $pipelineOptions): static + { + $clone = clone $this; + $clone->pipelineOptions = $pipelineOptions; + + return $clone; + } +} diff --git a/src/Event/PostRequestEvent.php b/src/Event/PostRequestEvent.php deleted file mode 100644 index d5c1f74..0000000 --- a/src/Event/PostRequestEvent.php +++ /dev/null @@ -1,30 +0,0 @@ -request; - } - - public function getResponse(): ResponseInterface - { - return $this->response; - } - - public function setResponse(ResponseInterface $response): void - { - $this->response = $response; - } -} \ No newline at end of file diff --git a/src/Event/PreRequestEvent.php b/src/Event/PreRequestEvent.php deleted file mode 100644 index cd01e78..0000000 --- a/src/Event/PreRequestEvent.php +++ /dev/null @@ -1,23 +0,0 @@ -request; - } - - public function setRequest(RequestInterface $request): void - { - $this->request = $request; - } -} \ No newline at end of file diff --git a/src/Event/ResponseContentsEvent.php b/src/Event/ResponseContentsEvent.php deleted file mode 100644 index da42560..0000000 --- a/src/Event/ResponseContentsEvent.php +++ /dev/null @@ -1,22 +0,0 @@ -contents; - } - - public function setContents($contents): void - { - $this->contents = $contents; - } -} \ No newline at end of file diff --git a/src/Helper/StringHelper.php b/src/Helper/StringHelper.php deleted file mode 100644 index f15612d..0000000 --- a/src/Helper/StringHelper.php +++ /dev/null @@ -1,16 +0,0 @@ -buildPath($path, $pathParams); + $query = $options->getQuery(); + $headers = $options->getHeaders(); + + if (!empty($this->defaultQueries)) { + $query = array_merge($this->defaultQueries, $query); + } + + if (!empty($this->defaultHeaders)) { + $headers = array_merge($this->defaultHeaders, $headers); + } + + $request = $this->createRequest( + method: $method, + url: $this->buildUrl($path, $query), + headers: $headers, + body: $options->getBody() + ); + + $request = $this->hookBuilder->applyBeforeRequestHooks( + new RequestContext($request, $context) + ); + + $response = $this->clientBuilder + ->getClient($this->buildPlugins($pipelineOptions)) + ->sendRequest($request); + + return $this->hookBuilder->applyAfterResponseHooks( + new ResponseContext($request, $response, $context) + ); + } + + private function buildPlugins(PipelineOptions $pipelineOptions): array + { + $plugins = new PluginBuilder(); + + // Internal plugins are registered before user plugins so custom plugins can + // still run before, between, or after them by choosing a priority. + $plugins->add( + plugin: new ContentTypePlugin(), + priority: self::CONTENT_TYPE_PLUGIN_PRIORITY + ); + + $plugins->add( + plugin: new ContentLengthPlugin(), + priority: self::CONTENT_LENGTH_PLUGIN_PRIORITY + ); + + if ($authentication = $this->authBuilder->getAuthentication()) { + $plugins->add( + plugin: new AuthenticationPlugin($authentication), + priority: self::AUTHENTICATION_PLUGIN_PRIORITY + ); + } + + if ($cachePlugin = $this->buildCachePlugin($pipelineOptions)) { + $plugins->add( + plugin: $cachePlugin, + priority: self::CACHE_PLUGIN_PRIORITY + ); + } + + if ($loggerPlugin = $this->buildLoggerPlugin()) { + $plugins->add( + plugin: $loggerPlugin, + priority: self::LOGGER_PLUGIN_PRIORITY + ); + } + + $plugins->merge($this->pluginBuilder); + + return $plugins->getPlugins(); + } + + private function buildCachePlugin(PipelineOptions $pipelineOptions): ?Plugin + { + if ($this->cacheBuilder === null) { + if ($pipelineOptions->has(PipelineOption::CACHE)) { + throw new \RuntimeException('Endpoint cache overrides require API-level cache configuration.'); + } + + return null; + } + + // Request-local pipeline options adjust a clone so endpoint defaults and + // resource overrides do not leak into the API-level cache configuration. + $cacheBuilder = clone $this->cacheBuilder; + $pipelineOptions->applyTo(PipelineOption::CACHE, $cacheBuilder); + + $cacheOptions = [ + 'default_ttl' => $cacheBuilder->getDefaultTtl(), + 'methods' => $cacheBuilder->getMethods(), + 'respect_response_cache_directives' => $cacheBuilder->getResponseCacheDirectives(), + 'cache_listeners' => [] + ]; + + if ($this->loggerBuilder) { + $cacheOptions['cache_listeners'][] = new CacheLoggerListener($this->loggerBuilder); + } + + return new CachePlugin( + $cacheBuilder->getPool(), + $this->clientBuilder->getStreamFactory(), + $cacheOptions + ); + } + + private function buildLoggerPlugin(): ?Plugin + { + if ($this->loggerBuilder === null) { + return null; + } + + return new LoggerPlugin( + $this->loggerBuilder->getLogger(), + $this->loggerBuilder->getFormatter() + ); + } + + private function buildPath(string $path, array $parameters): string + { + foreach ($parameters as $parameter => $value) { + $path = str_replace( + sprintf('{%s}', $parameter), + rawurlencode((string) $value), + $path + ); + } + + return $path; + } + + private function buildUrl(string $path, array $query = []): string + { + $query = array_filter($query, static fn(mixed $value): bool => $value !== null); + $appendQuery = http_build_query($query, '', '&', PHP_QUERY_RFC3986); + + $url = UrlHelper::join($this->baseUrl, $path); + + return append_query_string($url, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); + } + + private function createRequest( + string $method, + string $url, + array $headers = [], + string|StreamInterface|null $body = null + ): RequestInterface + { + $request = $this->clientBuilder->getRequestFactory()->createRequest($method, $url); + + foreach ($headers as $key => $value) { + $request = $request->withHeader($key, $value); + } + + if ($body !== null && $body !== '') { + $request = $request->withBody( + is_string($body) ? $this->clientBuilder->getStreamFactory()->createStream($body) : $body + ); + } + + return $request; + } +} diff --git a/src/Request/PipelineOption.php b/src/Request/PipelineOption.php new file mode 100644 index 0000000..678d07d --- /dev/null +++ b/src/Request/PipelineOption.php @@ -0,0 +1,10 @@ +> $defaults + * @param array> $overrides + */ + public function __construct( + private readonly array $defaults = [], + private readonly array $overrides = [] + ) {} + + public function has(string $key): bool + { + return !empty($this->defaults[$key]) || !empty($this->overrides[$key]); + } + + /** + * @param callable(object): mixed $configure + */ + public function withDefault(string $key, callable $configure): self + { + $defaults = $this->defaults; + $defaults[$key][] = $configure; + + return new self($defaults, $this->overrides); + } + + /** + * @param callable(object): mixed $configure + */ + public function withOverride(string $key, callable $configure): self + { + $overrides = $this->overrides; + $overrides[$key][] = $configure; + + return new self($this->defaults, $overrides); + } + + /** + * @template T of object + * @param T $builder + * @return T + */ + public function applyTo(string $key, object $builder): object + { + foreach ($this->configurers($key) as $configure) { + $configure($builder); + } + + return $builder; + } + + /** + * @return list + */ + private function configurers(string $key): array + { + return [ + ...($this->defaults[$key] ?? []), + ...($this->overrides[$key] ?? []), + ]; + } +} diff --git a/src/Request/RequestOptions.php b/src/Request/RequestOptions.php new file mode 100644 index 0000000..12bbccb --- /dev/null +++ b/src/Request/RequestOptions.php @@ -0,0 +1,73 @@ +query; + } + + public function getHeaders(): array + { + return $this->headers; + } + + public function getBody(): string|StreamInterface|null + { + return $this->body; + } + + public function withQuery(string $name, mixed $value): self + { + return $this->withQueries([$name => $value]); + } + + public function withQueries(array $query): self + { + return new self( + query: array_merge($this->query, $this->filterNullValues($query)), + headers: $this->headers, + body: $this->body + ); + } + + public function withHeader(string $name, mixed $value): self + { + return $this->withHeaders([$name => $value]); + } + + public function withHeaders(array $headers): self + { + return new self( + query: $this->query, + headers: array_merge($this->headers, $headers), + body: $this->body + ); + } + + public function withBody(string|StreamInterface|null $body): self + { + return new self( + query: $this->query, + headers: $this->headers, + body: $body + ); + } + + private function filterNullValues(array $values): array + { + // Null means "omit this request-local query value"; false, 0, and empty + // strings are still meaningful values and must be preserved. + return array_filter($values, static fn(mixed $value): bool => $value !== null); + } +} diff --git a/src/Resource.php b/src/Resource.php new file mode 100644 index 0000000..14d6d48 --- /dev/null +++ b/src/Resource.php @@ -0,0 +1,41 @@ +pipelineOptions = new PipelineOptions(); + } + + /** + * @param callable(CacheBuilder): mixed $configure + */ + public function withCache(callable $configure): static + { + return $this->withPipelineOptions( + $this->pipelineOptions->withOverride(PipelineOption::CACHE, $configure) + ); + } + + protected function endpoint(): Endpoint + { + return new Endpoint($this->runtime, $this->pipelineOptions); + } + + private function withPipelineOptions(PipelineOptions $pipelineOptions): static + { + $clone = clone $this; + $clone->pipelineOptions = $pipelineOptions; + + return $clone; + } +} diff --git a/src/Response/Response.php b/src/Response/Response.php new file mode 100644 index 0000000..7baabb5 --- /dev/null +++ b/src/Response/Response.php @@ -0,0 +1,131 @@ +context = $context ?? new Context(); + } + + private readonly Context $context; + + public function data(): mixed + { + return $this->data; + } + + public function raw(): ResponseInterface + { + return $this->rawResponse; + } + + /** + * @template T of EntityInterface + * @param class-string $class + * @return T + */ + public function entity(string $class, ?string $key = null): EntityInterface + { + $this->assertEntityClass($class); + + $data = $this->getData($key); + + if (!is_array($data)) { + throw new \UnexpectedValueException('Entity data must be an array.'); + } + + return $class::fromArray($data, $this->context); + } + + /** + * @template T of EntityInterface + * @param class-string $class + * @return T[] + */ + public function collection(string $class, ?string $key = null): array + { + $this->assertEntityClass($class); + + $items = $this->getData($key); + + if (!is_array($items)) { + throw new \UnexpectedValueException('Collection data must be an array.'); + } + + $context = $this->context; + + return array_map(static function (mixed $item) use ($class, $context): EntityInterface { + if (!is_array($item)) { + throw new \UnexpectedValueException('Collection item data must be an array.'); + } + + return $class::fromArray($item, $context); + }, $items); + } + + /** + * @template T of EnvelopeInterface + * @param class-string $class + * @return T + */ + public function envelope(string $class): EnvelopeInterface + { + $this->assertEnvelopeClass($class); + + return $class::fromResponse($this, $this->context); + } + + private function getData(?string $key): mixed + { + if ($key === null) { + return $this->data; + } + + if (!is_array($this->data) || !array_key_exists($key, $this->data)) { + throw new \UnexpectedValueException(sprintf( + 'Response data key "%s" does not exist.', + $key + )); + } + + return $this->data[$key]; + } + + /** + * @param class-string $class + */ + private function assertEntityClass(string $class): void + { + if (!is_subclass_of($class, EntityInterface::class)) { + throw new \InvalidArgumentException(sprintf( + 'Entity class "%s" must implement %s.', + $class, + EntityInterface::class + )); + } + } + + /** + * @param class-string $class + */ + private function assertEnvelopeClass(string $class): void + { + if (!is_subclass_of($class, EnvelopeInterface::class)) { + throw new \InvalidArgumentException(sprintf( + 'Envelope class "%s" must implement %s.', + $class, + EnvelopeInterface::class + )); + } + } +} diff --git a/src/Response/ResponseDecoder.php b/src/Response/ResponseDecoder.php new file mode 100644 index 0000000..4c0a583 --- /dev/null +++ b/src/Response/ResponseDecoder.php @@ -0,0 +1,79 @@ +getBody()->rewind(); + $contents = $response->getBody()->getContents(); + + return match ($this->responseBuilder->getFormat()) { + ResponseFormat::Raw => $contents, + ResponseFormat::Json => $this->decodeJson($contents), + ResponseFormat::Xml => $this->decodeXml($contents), + ResponseFormat::Custom => $this->decodeCustom($response), + }; + } + + /** + * @throws \JsonException + */ + private function decodeJson(string $contents): mixed + { + return $contents === '' + ? null + : json_decode($contents, true, flags: JSON_THROW_ON_ERROR); + } + + private function decodeXml(string $contents): ?SimpleXMLElement + { + if ($contents === '') { + return null; + } + + // libxml error handling is global, so restore the previous setting after + // parsing to avoid changing behavior for calling applications. + $previous = libxml_use_internal_errors(true); + libxml_clear_errors(); + + $xml = simplexml_load_string($contents); + $errors = libxml_get_errors(); + + libxml_clear_errors(); + libxml_use_internal_errors($previous); + + if (!$xml instanceof SimpleXMLElement) { + $message = $errors[0]->message ?? 'Invalid XML response body.'; + + throw new RuntimeException(trim($message)); + } + + return $xml; + } + + private function decodeCustom(ResponseInterface $response): mixed + { + $decoder = $this->responseBuilder->getCustomDecoder(); + + if ($decoder === null) { + throw new RuntimeException('A custom response decoder must be configured.'); + } + + return $decoder($response); + } +} diff --git a/src/Response/ResponseFormat.php b/src/Response/ResponseFormat.php new file mode 100644 index 0000000..21c4118 --- /dev/null +++ b/src/Response/ResponseFormat.php @@ -0,0 +1,11 @@ +config; + } + + /** + * @throws ClientExceptionInterface + * @throws \JsonException + * @throws \RuntimeException + * @throws \Throwable + */ + public function send( + string $method, + string $path, + array $pathParams, + RequestOptions $requestOptions, + PipelineOptions $pipelineOptions + ): Response { + $context = new Context($this->config); + + $rawResponse = ($this->transport)()->send( + method: $method, + path: $path, + pathParams: $pathParams, + options: $requestOptions, + pipelineOptions: $pipelineOptions, + context: $context + ); + + $response = new Response( + data: $this->responseDecoder->decode($rawResponse), + rawResponse: $rawResponse, + context: $context + ); + + $this->errorBuilder->throwIfMatched(new ErrorContext($response, $context)); + + return $response; + } +} diff --git a/src/Setup.php b/src/Setup.php new file mode 100644 index 0000000..63d7a05 --- /dev/null +++ b/src/Setup.php @@ -0,0 +1,111 @@ +call('baseUrl', [$baseUrl]); + + return $this; + } + + public function defaultQuery(string $name, mixed $value): self + { + $this->call('defaultQuery', [$name, $value]); + + return $this; + } + + public function defaultQueries(array $query): self + { + $this->call('defaultQueries', [$query]); + + return $this; + } + + public function defaultHeader(string $name, mixed $value): self + { + $this->call('defaultHeader', [$name, $value]); + + return $this; + } + + public function defaultHeaders(array $headers): self + { + $this->call('defaultHeaders', [$headers]); + + return $this; + } + + public function responses(): ResponseBuilder + { + return $this->call('responses'); + } + + public function errors(): ErrorBuilder + { + return $this->call('errors'); + } + + public function auth(): AuthBuilder + { + return $this->call('auth'); + } + + public function hooks(): HookBuilder + { + return $this->call('hooks'); + } + + public function plugins(): PluginBuilder + { + return $this->call('plugins'); + } + + public function cache(CacheItemPoolInterface $pool): CacheBuilder + { + return $this->call('cache', [$pool]); + } + + public function client(ClientInterface $client): ClientBuilder + { + return $this->call('client', [$client]); + } + + public function logger(LoggerInterface $logger): LoggerBuilder + { + return $this->call('logger', [$logger]); + } + + public function config(array $values = [], array $defaults = []): Config + { + return $this->call('config', [$values, $defaults]); + } + + private function call(string $method, array $arguments = []): mixed + { + return ($this->call)($method, $arguments); + } +} diff --git a/src/Test/AbstractTestCase.php b/src/Test/AbstractTestCase.php deleted file mode 100644 index 497ef67..0000000 --- a/src/Test/AbstractTestCase.php +++ /dev/null @@ -1,10 +0,0 @@ -hasHeader('Authorization') ? 'present' : 'missing'; + + return $next($request->withAddedHeader('X-Auth-State', sprintf('%s:%s', $this->label, $state))); + } +} diff --git a/tests/Fixture/FakeApi.php b/tests/Fixture/FakeApi.php new file mode 100644 index 0000000..c14e33c --- /dev/null +++ b/tests/Fixture/FakeApi.php @@ -0,0 +1,42 @@ +client($client); + $this->config(['timezone' => 'UTC']); + + $this + ->baseUrl('https://api.example.com') + ->defaultQueries(['locale' => 'en']) + ->responses() + ->json(); + } + + public function users(): UserResource + { + return $this->resource(UserResource::class); + } + + public function withDefaultQuery(string $name, mixed $value): self + { + $this->defaultQuery($name, $value); + + return $this; + } + + public function withDefaultHeader(string $name, mixed $value): self + { + $this->defaultHeader($name, $value); + + return $this; + } +} diff --git a/tests/Fixture/HeaderPlugin.php b/tests/Fixture/HeaderPlugin.php new file mode 100644 index 0000000..b518c16 --- /dev/null +++ b/tests/Fixture/HeaderPlugin.php @@ -0,0 +1,25 @@ +append + ? $request->withAddedHeader($this->name, $this->value) + : $request->withHeader($this->name, $this->value); + + return $next($request); + } +} diff --git a/tests/Fixture/InvalidApiKeyException.php b/tests/Fixture/InvalidApiKeyException.php new file mode 100644 index 0000000..08b5da6 --- /dev/null +++ b/tests/Fixture/InvalidApiKeyException.php @@ -0,0 +1,7 @@ +client($client); + + $this + ->baseUrl('https://api.example.com') + ->responses() + ->json(); + } + + public function throwNotFoundErrors(): self + { + $this->errors()->status(404, function (ErrorContext $context): \Throwable { + return new NotFoundException($context->response()->data()['message']); + }); + + return $this; + } + + public function throwSimpleNotFoundErrors(): self + { + $this->errors()->status(404, NotFoundException::class); + + return $this; + } + + public function throwStatusErrors(): self + { + $this->errors()->statuses([ + 401 => InvalidApiKeyException::class, + 404 => NotFoundException::class, + ]); + + return $this; + } + + public function throwInvalidApiKeyErrors(): self + { + $this->errors()->when(function (ErrorContext $context): ?\Throwable { + if (($context->response()->data()['code'] ?? null) !== 'invalid_api_key') { + return null; + } + + return new InvalidApiKeyException($context->response()->data()['message']); + }); + + return $this; + } + + public function useBearerAuth(string $token): self + { + $this->auth()->bearer($token); + + return $this; + } + + public function useBasicAuth(string $username, string $password): self + { + $this->auth()->basic($username, $password); + + return $this; + } + + public function useHeaderAuth(string $name, string $value): self + { + $this->auth()->header($name, $value); + + return $this; + } + + public function useQueryAuth(string $name, string $value): self + { + $this->auth()->query($name, $value); + + return $this; + } + + public function useWsseAuth(string $username, string $password): self + { + $this->auth()->wsse($username, $password); + + return $this; + } + + public function useConditionalAuth(): self + { + $this->auth()->conditional( + new RequestMatcher(path: '^/raw'), + new HeaderAuthentication('X-Conditional-Auth', 'conditional') + ); + + return $this; + } + + public function useChainedAuth(string $headerName, string $headerValue): self + { + $this->auth()->chain(new HeaderAuthentication($headerName, $headerValue)); + + return $this; + } + + public function useCustomAuth(string $headerName, string $headerValue): self + { + $this->auth()->custom(function (RequestInterface $request) use ($headerName, $headerValue): RequestInterface { + return $request->withHeader($headerName, $headerValue); + }); + + return $this; + } + + public function usePlugin(Plugin $plugin, int $priority = 0): self + { + $this->plugins()->add($plugin, $priority); + + return $this; + } + + /** + * @param callable(RequestContext): mixed $hook + */ + public function beforeRequest(callable $hook, int $priority = 0): self + { + $this->hooks()->beforeRequest($hook, $priority); + + return $this; + } + + /** + * @param callable(ResponseContext): mixed $hook + */ + public function afterResponse(callable $hook, int $priority = 0): self + { + $this->hooks()->afterResponse($hook, $priority); + + return $this; + } + + public function raw(): RawResource + { + return $this->resource(RawResource::class); + } +} diff --git a/tests/Fixture/NotFoundException.php b/tests/Fixture/NotFoundException.php new file mode 100644 index 0000000..7747bc7 --- /dev/null +++ b/tests/Fixture/NotFoundException.php @@ -0,0 +1,7 @@ +client($client); + $this->baseUrl('https://api.example.com'); + } + + /** + * @param callable(ResponseInterface): mixed $decoder + */ + public function decodeWith(callable $decoder): self + { + $this->responses()->custom($decoder); + + return $this; + } + + public function raw(): RawResource + { + return $this->resource(RawResource::class); + } +} diff --git a/tests/Fixture/RawResource.php b/tests/Fixture/RawResource.php new file mode 100644 index 0000000..1743cfb --- /dev/null +++ b/tests/Fixture/RawResource.php @@ -0,0 +1,19 @@ +endpoint()->get('/raw'); + } + + public function absolute(string $url): Response + { + return $this->endpoint()->get($url); + } +} diff --git a/tests/Fixture/User.php b/tests/Fixture/User.php new file mode 100644 index 0000000..718fe84 --- /dev/null +++ b/tests/Fixture/User.php @@ -0,0 +1,39 @@ +config()->get('timezone') + ); + } + + public function getId(): int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function getTimezone(): ?string + { + return $this->timezone; + } +} diff --git a/tests/Fixture/UserEnvelope.php b/tests/Fixture/UserEnvelope.php new file mode 100644 index 0000000..d805722 --- /dev/null +++ b/tests/Fixture/UserEnvelope.php @@ -0,0 +1,40 @@ +entity(User::class, key: 'data'), + statusCode: $response->raw()->getStatusCode(), + timezone: $context?->config()->get('timezone') + ); + } + + public function getUser(): User + { + return $this->user; + } + + public function getStatusCode(): int + { + return $this->statusCode; + } + + public function getTimezone(): ?string + { + return $this->timezone; + } +} diff --git a/tests/Fixture/UserResource.php b/tests/Fixture/UserResource.php new file mode 100644 index 0000000..c415488 --- /dev/null +++ b/tests/Fixture/UserResource.php @@ -0,0 +1,153 @@ +status = $status; + + return $clone; + } + + public function sendWithVerb(string $verb): void + { + match ($verb) { + 'GET' => $this->endpoint()->get('/users'), + 'POST' => $this->endpoint()->post('/users'), + 'PUT' => $this->endpoint()->put('/users/{id}', ['id' => 1]), + 'PATCH' => $this->endpoint()->patch('/users/{id}', ['id' => 1]), + 'DELETE' => $this->endpoint()->delete('/users/{id}', ['id' => 1]), + 'HEAD' => $this->endpoint()->head('/users'), + 'OPTIONS' => $this->endpoint()->options('/users'), + 'CONNECT' => $this->endpoint()->connect('/users'), + 'TRACE' => $this->endpoint()->trace('/users'), + }; + } + + public function createWithJson(array $data): void + { + $this->endpoint()->json($data)->post('/users'); + } + + public function createWithForm(array $data): void + { + $this->endpoint()->form($data)->post('/users'); + } + + public function createWithBody(string|StreamInterface|null $body): void + { + $this->endpoint()->body($body)->post('/users'); + } + + public function createWithInvalidBody(mixed $body): void + { + $this->endpoint()->body($body)->post('/users'); + } + + public function createWithEndpointCache(array $data): Response + { + return $this + ->endpoint() + ->cache(fn($cache) => $cache->methods(['POST'])) + ->json($data) + ->post('/users'); + } + + public function createWithChainedEndpointCache(array $data): Response + { + return $this + ->endpoint() + ->cache(fn($cache) => $cache->methods(['POST'])) + ->cache(fn($cache) => $cache->methods(['GET'])) + ->json($data) + ->post('/users'); + } + + public function all(): array + { + return $this + ->endpoint() + ->query('status', $this->status) + ->get('/users') + ->collection(User::class, key: 'data'); + } + + public function find(int|string $id): User + { + return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + + public function findFromEnvelope(int|string $id): User + { + return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class, key: 'data'); + } + + public function findEnvelope(int|string $id): UserEnvelope + { + return $this + ->endpoint() + ->get('/users/{id}', ['id' => $id]) + ->envelope(UserEnvelope::class); + } + + public function findWithEndpointLocale(int|string $id, string $locale): User + { + return $this + ->endpoint() + ->query('locale', $locale) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + + public function findWithEndpointOptions(int|string $id): User + { + return $this + ->endpoint() + ->queries(['active' => true]) + ->headers(['X-Tenant' => 'acme']) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + + public function findWithConfiguredTimezone(int|string $id): User + { + return $this + ->endpoint() + ->query('timezone', $this->runtime->config()->get('timezone')) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + + public function findWithActive(int|string $id, bool $active = true): User + { + return $this + ->endpoint() + ->query('active', $active) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } + + public function findWithEmptyQuery(int|string $id): User + { + return $this + ->endpoint() + ->query('empty', null) + ->get('/users/{id}', ['id' => $id]) + ->entity(User::class); + } +} diff --git a/tests/Fixture/XmlApi.php b/tests/Fixture/XmlApi.php new file mode 100644 index 0000000..ee9b0a7 --- /dev/null +++ b/tests/Fixture/XmlApi.php @@ -0,0 +1,26 @@ +client($client); + + $this + ->baseUrl('https://api.example.com') + ->responses() + ->xml(); + } + + public function raw(): RawResource + { + return $this->resource(RawResource::class); + } +} diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index 5d8bcaf..7b6983a 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -2,240 +2,200 @@ namespace ProgrammatorDev\Api\Test\Integration; -use Http\Message\Authentication; -use Http\Mock\Client; use Nyholm\Psr7\Response; -use PHPUnit\Framework\Attributes\DataProvider; use ProgrammatorDev\Api\Api; -use ProgrammatorDev\Api\Builder\CacheBuilder; -use ProgrammatorDev\Api\Builder\ClientBuilder; -use ProgrammatorDev\Api\Builder\LoggerBuilder; -use ProgrammatorDev\Api\Event\PreRequestEvent; -use ProgrammatorDev\Api\Event\ResponseContentsEvent; -use ProgrammatorDev\Api\Test\AbstractTestCase; -use ProgrammatorDev\Api\Test\MockResponse; -use Psr\Cache\CacheItemPoolInterface; +use ProgrammatorDev\Api\Context\RequestContext; +use ProgrammatorDev\Api\Context\ResponseContext; +use ProgrammatorDev\Api\Http\Method; +use ProgrammatorDev\Api\Test\Fixture\FakeApi; +use ProgrammatorDev\Api\Test\Fixture\HeaderPlugin; +use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use Psr\Http\Message\RequestInterface; -use Psr\Log\LoggerInterface; +use Symfony\Component\Cache\Adapter\ArrayAdapter; class ApiTest extends AbstractTestCase { - private const BASE_URL = 'https://base.com/url'; - - private Api $api; - - private Client $mockClient; - - protected function setUp(): void + public function testConfigCanBeSetAndReadBySdkApi(): void { - parent::setUp(); + $api = new class extends Api {}; - // create anonymous class - $this->api = new class extends Api {}; + $api + ->config(['timezone' => 'UTC'], defaults: ['timezone' => 'Europe/Lisbon']) + ->merge(['units' => 'metric']); - // set mock client - $this->mockClient = new Client(); - $this->api->setClientBuilder(new ClientBuilder($this->mockClient)); + $this->assertSame('UTC', $api->config()->get('timezone')); + $this->assertSame('metric', $api->config()->get('units')); + $this->assertSame('en', $api->config()->get('locale', 'en')); + $this->assertSame([ + 'timezone' => 'UTC', + 'units' => 'metric', + ], $api->config()->all()); } - public function testRequest() + public function testApiCanSendPublicRequest(): void { - $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); - $response = $this->api->request( - method: 'GET', - path: '/path' - ); + $response = (new FakeApi($client))->send(Method::GET, '/users/{id}', ['id' => 1]); - $this->assertSame(MockResponse::SUCCESS, $response); + $this->assertSame(['id' => 1, 'name' => 'John'], $response->data()); + $this->assertSame('https://api.example.com/users/1?locale=en', (string) $client->getLastRequest()->getUri()); } - public function testMultipleRequests() + public function testApiCanSendPublicRequestWithQueryHeadersAndBody(): void { - $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); - $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); - - $this->api->request(method: 'GET', path: '/path-1'); - $this->api->request(method: 'GET', path: '/path-2'); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); - $this->assertTrue(true); - } - - public function testBaseUrl() - { - $this->assertNull($this->api->getBaseUrl()); + (new FakeApi($client))->send( + method: Method::POST, + path: '/users', + query: ['active' => true], + headers: ['Content-Type' => 'application/json'], + body: '{"name":"John"}' + ); - $this->api->setBaseUrl(self::BASE_URL); + $request = $client->getLastRequest(); - $this->assertSame(self::BASE_URL, $this->api->getBaseUrl()); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://api.example.com/users?locale=en&active=1', (string) $request->getUri()); + $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); + $this->assertSame('{"name":"John"}', (string) $request->getBody()); } - public function testQueryDefaults() + public function testApiCanSendRequestWithDefaultQuery(): void { - $this->api->addQueryDefault('test', true); - $this->assertTrue($this->api->getQueryDefault('test')); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); - $this->api->removeQueryDefault('test'); - $this->assertNull($this->api->getQueryDefault('test')); - } - - public function testHeaderDefaults() - { - $this->api->addHeaderDefault('X-Test', true); - $this->assertTrue($this->api->getHeaderDefault('X-Test')); + (new FakeApi($client)) + ->withDefaultQuery('units', 'metric') + ->send(Method::GET, '/users/{id}', ['id' => 1]); - $this->api->removeHeaderDefault('X-Test'); - $this->assertNull($this->api->getHeaderDefault('X-Test')); + $this->assertSame('https://api.example.com/users/1?locale=en&units=metric', (string) $client->getLastRequest()->getUri()); } - public function testCache() + public function testApiCanUseConfigValuesAsDefaultQueries(): void { - $this->assertNull($this->api->getCacheBuilder()); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); - $cachePool = $this->createMock(CacheItemPoolInterface::class); + $api = new class extends Api {}; + $setup = $api->setup(); - $this->api->setCacheBuilder(new CacheBuilder($cachePool)); + $setup->client($client); + $setup + ->baseUrl('https://api.example.com') + ->defaultQueries($api->config([ + 'locale' => 'pt', + 'version' => 'v2', + 'internal' => true, + ])->only('locale', 'version')); + $setup->responses()->json(); - $cachePool->expects($this->once())->method('save'); + $api->send(Method::GET, '/users/{id}', ['id' => 1]); - $this->api->request( - method: 'GET', - path: '/path' - ); + $this->assertSame('https://api.example.com/users/1?locale=pt&version=v2', (string) $client->getLastRequest()->getUri()); } - public function testLogger() + public function testApiCanSendRequestWithDefaultHeader(): void { - $this->assertNull($this->api->getLoggerBuilder()); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); - $logger = $this->createMock(LoggerInterface::class); + (new FakeApi($client)) + ->withDefaultHeader('Accept', 'application/json') + ->send(Method::GET, '/users/{id}', ['id' => 1]); - $this->api->setLoggerBuilder(new LoggerBuilder($logger)); - - // count equals 2 because of the request and response log - $logger->expects($this->exactly(2))->method('info'); - - $this->api->request( - method: 'GET', - path: '/path' - ); + $this->assertSame('application/json', $client->getLastRequest()->getHeaderLine('Accept')); } - public function testCacheWithLogger() + public function testApiSetupCanConfigureRequestBehavior(): void { - $this->assertNull($this->api->getCacheBuilder()); - $this->assertNull($this->api->getLoggerBuilder()); + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); - $cachePool = $this->createMock(CacheItemPoolInterface::class); - $logger = $this->createMock(LoggerInterface::class); + $api = new class extends Api {}; + $setup = $api->setup(); - $this->api->setCacheBuilder(new CacheBuilder($cachePool)); - $this->api->setLoggerBuilder(new LoggerBuilder($logger)); + $setup->client($client); + $setup + ->baseUrl('https://api.example.com') + ->defaultQuery('locale', 'en') + ->defaultHeader('Accept', 'application/json'); - // count equals 3 because of the request, response and cache log - $logger->expects($this->exactly(3))->method('info'); + $setup->responses()->json(); - // error suppression to hide expected warning of null cache item in CacheLoggerListener - // https://docs.phpunit.de/en/10.5/error-handling.html#ignoring-issue-suppression - // TODO maybe allow user to add cache listeners to CacheBuilder and create a mock? - @$this->api->request( - method: 'GET', - path: '/path' - ); + $response = $api->send(Method::GET, '/users/{id}', ['id' => 1]); + + $this->assertSame(['id' => 1, 'name' => 'John'], $response->data()); + $this->assertSame('https://api.example.com/users/1?locale=en', (string) $client->getLastRequest()->getUri()); + $this->assertSame('application/json', $client->getLastRequest()->getHeaderLine('Accept')); } - public function testAuthentication() + public function testApiSendUsesConfiguredPipeline(): void { - $this->assertNull($this->api->getAuthentication()); - - $authentication = $this->createConfiguredMock(Authentication::class, [ - 'authenticate' => $this->createMock(RequestInterface::class) - ]); + $client = $this->mockClient(new Response(body: '{"ok":false}')); - $this->api->setAuthentication($authentication); + $api = new class extends Api {}; + $setup = $api->setup(); - $authentication->expects($this->once())->method('authenticate'); + $setup->client($client); + $setup->baseUrl('https://api.example.com'); - $this->api->request( - method: 'GET', - path: '/path' + $setup->auth()->header('X-Auth', 'secret'); + $setup->plugins()->add(new HeaderPlugin('X-Plugin', 'plugin')); + $setup->hooks()->beforeRequest( + fn (RequestContext $context): RequestInterface => $context->request()->withHeader('X-Before-Hook', 'before') ); - } - - public function testPreRequestListener() - { - $this->api->addPreRequestListener(fn() => throw new \Exception('TestMessage')); + $setup->hooks()->afterResponse( + fn (ResponseContext $context): Response => new Response(body: '{"ok":true}') + ); + $setup->responses()->json(); - $this->expectException(\Exception::class); - $this->expectExceptionMessage('TestMessage'); + $response = $api->send(Method::GET, '/status'); - $this->api->request( - method: 'GET', - path: '/path' - ); + $this->assertSame(['ok' => true], $response->data()); + $this->assertSame('secret', $client->getLastRequest()->getHeaderLine('X-Auth')); + $this->assertSame('plugin', $client->getLastRequest()->getHeaderLine('X-Plugin')); + $this->assertSame('before', $client->getLastRequest()->getHeaderLine('X-Before-Hook')); } - public function testPostRequestListener() + public function testApiSendUsesConfiguredErrors(): void { - $this->api->addPostRequestListener(fn() => throw new \Exception('TestMessage')); - - $this->expectException(\Exception::class); - $this->expectExceptionMessage('TestMessage'); + $client = $this->mockClient(new Response(status: 404, body: '{"message":"Missing"}')); - $this->api->request( - method: 'GET', - path: '/path' - ); - } + $api = new class extends Api {}; + $setup = $api->setup(); - public function testResponseContentsListener() - { - $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); + $setup->client($client); + $setup->baseUrl('https://api.example.com'); - $this->api->addResponseContentsListener(function(ResponseContentsEvent $event) { - $contents = json_decode($event->getContents(), true); - $event->setContents($contents); - }); + $setup->responses()->json(); + $setup->errors()->status(404, fn (): \Throwable => new \RuntimeException('Missing')); - $response = $this->api->request( - method: 'GET', - path: '/path' - ); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing'); - $this->assertIsArray($response); + $api->send(Method::GET, '/missing'); } - #[DataProvider('provideBuildUrlData')] - public function testBuildUrl(?string $baseUrl, string $path, array $query, string $expectedUrl) + public function testApiSendUsesConfiguredCache(): void { - $this->api->addPreRequestListener(function(PreRequestEvent $event) use ($expectedUrl) { - $url = (string) $event->getRequest()->getUri(); + $client = $this->mockClient(new Response( + headers: ['Cache-Control' => 'max-age=60'], + body: '{"id":1}' + )); - $this->assertSame($expectedUrl, $url); - }); + $api = new class extends Api {}; + $setup = $api->setup(); - $this->api->setBaseUrl($baseUrl); - $this->api->request(method: 'GET', path: $path, query: $query); - } + $setup->client($client); + $setup->baseUrl('https://api.example.com'); - public static function provideBuildUrlData(): \Generator - { - yield 'no base url' => [null, '/path', [], '/path']; - yield 'base url' => [self::BASE_URL, '/path', [], 'https://base.com/url/path']; - yield 'path full url' => [self::BASE_URL, 'https://fullurl.com/path', [], 'https://fullurl.com/path']; - yield 'duplicated slashes' => [self::BASE_URL, '////path', [], 'https://base.com/url/path']; - yield 'query' => [self::BASE_URL, '/path', ['foo' => 'bar'], 'https://base.com/url/path?foo=bar']; - yield 'path query' => [self::BASE_URL, '/path?test=true', ['foo' => 'bar'], 'https://base.com/url/path?test=true&foo=bar']; - yield 'query replace' => [self::BASE_URL, '/path?test=true', ['test' => 'false'], 'https://base.com/url/path?test=false']; - } + $setup->cache(new ArrayAdapter())->defaultTtl(60); + $setup->responses()->json(); - public function testBuildPath() - { - $path = $this->api->buildPath('/path/{parameter1}/multiple/{parameter2}', [ - 'parameter1' => 'with', - 'parameter2' => 'parameters' - ]); + $first = $api->send(Method::GET, '/users/{id}', ['id' => 1]); + $second = $api->send(Method::GET, '/users/{id}', ['id' => 1]); - $this->assertSame('/path/with/multiple/parameters', $path); + $this->assertSame(['id' => 1], $first->data()); + $this->assertSame(['id' => 1], $second->data()); + $this->assertCount(1, $client->getRequests()); } -} \ No newline at end of file +} diff --git a/tests/Integration/AuthenticationTest.php b/tests/Integration/AuthenticationTest.php new file mode 100644 index 0000000..464d86a --- /dev/null +++ b/tests/Integration/AuthenticationTest.php @@ -0,0 +1,136 @@ +mockClient(); + + (new JsonApi($client)) + ->useBearerAuth('secret') + ->raw() + ->fetch(); + + $this->assertSame('Bearer secret', $client->getLastRequest()->getHeaderLine('Authorization')); + } + + public function testBasicAuthenticationAddsAuthorizationHeader(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useBasicAuth('user', 'pass') + ->raw() + ->fetch(); + + $this->assertSame('Basic ' . base64_encode('user:pass'), $client->getLastRequest()->getHeaderLine('Authorization')); + } + + public function testHeaderAuthenticationAddsConfiguredHeader(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useHeaderAuth('X-Api-Key', 'secret') + ->raw() + ->fetch(); + + $this->assertSame('secret', $client->getLastRequest()->getHeaderLine('X-Api-Key')); + } + + public function testQueryAuthenticationAddsConfiguredQueryParameter(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useQueryAuth('appid', 'secret') + ->raw() + ->fetch(); + + $query = $this->queryFromLastRequest($client); + + $this->assertSame('secret', $query['appid']); + } + + public function testWsseAuthenticationAddsWsseHeaders(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useWsseAuth('user', 'pass') + ->raw() + ->fetch(); + + $this->assertSame('WSSE profile="UsernameToken"', $client->getLastRequest()->getHeaderLine('Authorization')); + $this->assertStringContainsString('UsernameToken Username="user"', $client->getLastRequest()->getHeaderLine('X-WSSE')); + } + + public function testConditionalAuthenticationAddsAuthenticationWhenMatched(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useConditionalAuth() + ->raw() + ->fetch(); + + $this->assertSame('conditional', $client->getLastRequest()->getHeaderLine('X-Conditional-Auth')); + } + + public function testConditionalAuthenticationDoesNotAddAuthenticationWhenUnmatched(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useConditionalAuth() + ->raw() + ->absolute('https://api.example.com/users'); + + $this->assertSame('', $client->getLastRequest()->getHeaderLine('X-Conditional-Auth')); + } + + public function testChainedAuthenticationCanBeUsed(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useChainedAuth('X-Chain-Auth', 'chain') + ->raw() + ->fetch(); + + $this->assertSame('chain', $client->getLastRequest()->getHeaderLine('X-Chain-Auth')); + } + + public function testConfiguredAuthenticationReplacesPreviousAuthentication(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useBearerAuth('secret') + ->useQueryAuth('appid', 'key') + ->raw() + ->fetch(); + + $query = $this->queryFromLastRequest($client); + + $this->assertSame('', $client->getLastRequest()->getHeaderLine('Authorization')); + $this->assertSame('key', $query['appid']); + } + + public function testCustomAuthenticationCallbackCanBeUsed(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useCustomAuth('X-Custom-Auth', 'custom') + ->raw() + ->fetch(); + + $this->assertSame('custom', $client->getLastRequest()->getHeaderLine('X-Custom-Auth')); + } +} diff --git a/tests/Integration/CacheTest.php b/tests/Integration/CacheTest.php new file mode 100644 index 0000000..3d4a48c --- /dev/null +++ b/tests/Integration/CacheTest.php @@ -0,0 +1,101 @@ +mockClient(new Response( + headers: ['Cache-Control' => 'max-age=60'], + body: '{"id":1}' + )); + + $api = new JsonApi($client); + $api + ->setup()->cache(new ArrayAdapter()) + ->defaultTtl(60); + + $first = $api->raw()->fetch(); + $second = $api->raw()->fetch(); + + $this->assertSame(['id' => 1], $first->data()); + $this->assertSame(['id' => 1], $second->data()); + $this->assertCount(1, $client->getRequests()); + } + + public function testEndpointCanOverrideCacheConfiguration(): void + { + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); + $api = new FakeApi($client); + $api->setup()->cache(new ArrayAdapter())->methods(['GET']); + + $first = $api->users()->createWithEndpointCache(['name' => 'John']); + $second = $api->users()->createWithEndpointCache(['name' => 'John']); + + $this->assertSame(['id' => 1, 'name' => 'John'], $first->data()); + $this->assertSame(['id' => 1, 'name' => 'John'], $second->data()); + $this->assertCount(1, $client->getRequests()); + } + + public function testResourceCacheOverrideWinsOverEndpointCacheDefault(): void + { + $client = $this->mockClient( + new Response(body: '{"id":1,"name":"John"}'), + new Response(body: '{"id":2,"name":"Jane"}') + ); + $api = new FakeApi($client); + $api->setup()->cache(new ArrayAdapter())->methods(['GET']); + + $first = $api + ->users() + ->withCache(fn($cache) => $cache->methods(['GET'])) + ->createWithEndpointCache(['name' => 'John']); + + $second = $api + ->users() + ->withCache(fn($cache) => $cache->methods(['GET'])) + ->createWithEndpointCache(['name' => 'John']); + + $this->assertSame(['id' => 1, 'name' => 'John'], $first->data()); + $this->assertSame(['id' => 2, 'name' => 'Jane'], $second->data()); + $this->assertCount(2, $client->getRequests()); + } + + public function testEndpointCacheOverridesAreAppliedInFluentOrder(): void + { + $client = $this->mockClient( + new Response(body: '{"id":1,"name":"John"}'), + new Response(body: '{"id":2,"name":"Jane"}') + ); + $api = new FakeApi($client); + $api->setup()->cache(new ArrayAdapter())->methods(['GET']); + + $first = $api->users()->createWithChainedEndpointCache(['name' => 'John']); + $second = $api->users()->createWithChainedEndpointCache(['name' => 'John']); + + $this->assertSame(['id' => 1, 'name' => 'John'], $first->data()); + $this->assertSame(['id' => 2, 'name' => 'Jane'], $second->data()); + $this->assertCount(2, $client->getRequests()); + } + + public function testResourceCacheOverrideRequiresGlobalCacheConfiguration(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Endpoint cache overrides require API-level cache configuration.'); + + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); + $api = new FakeApi($client); + + $api + ->users() + ->withCache(fn($cache) => $cache->defaultTtl(60)) + ->find(1); + } +} diff --git a/tests/Integration/ErrorHandlingTest.php b/tests/Integration/ErrorHandlingTest.php new file mode 100644 index 0000000..e712585 --- /dev/null +++ b/tests/Integration/ErrorHandlingTest.php @@ -0,0 +1,85 @@ +mockClient(new Response(status: 404, body: '{"message":"Missing user"}')); + + $response = (new JsonApi($client))->raw()->fetch(); + + $this->assertSame(404, $response->raw()->getStatusCode()); + $this->assertSame(['message' => 'Missing user'], $response->data()); + } + + public function testConfiguredStatusErrorThrows(): void + { + $client = $this->mockClient(new Response(status: 404, body: '{"message":"Missing user"}')); + + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage('Missing user'); + + (new JsonApi($client)) + ->throwNotFoundErrors() + ->raw() + ->fetch(); + } + + public function testConfiguredStatusErrorCanMapDirectlyToThrowableClass(): void + { + $client = $this->mockClient(new Response(status: 404, body: '{"message":"Missing user"}')); + + $this->expectException(NotFoundException::class); + + (new JsonApi($client)) + ->throwSimpleNotFoundErrors() + ->raw() + ->fetch(); + } + + public function testConfiguredStatusErrorCanMapMultipleStatuses(): void + { + $client = $this->mockClient(new Response(status: 401, body: '{"message":"Invalid API key"}')); + + $this->expectException(InvalidApiKeyException::class); + + (new JsonApi($client)) + ->throwStatusErrors() + ->raw() + ->fetch(); + } + + public function testConfiguredCustomErrorHandlerThrowsWhenMatched(): void + { + $client = $this->mockClient(new Response(status: 401, body: '{"code":"invalid_api_key","message":"Invalid API key"}')); + + $this->expectException(InvalidApiKeyException::class); + $this->expectExceptionMessage('Invalid API key'); + + (new JsonApi($client)) + ->throwInvalidApiKeyErrors() + ->raw() + ->fetch(); + } + + public function testConfiguredCustomErrorHandlerDoesNotThrowWhenUnmatched(): void + { + $client = $this->mockClient(new Response(status: 401, body: '{"code":"rate_limited","message":"Too many requests"}')); + + $response = (new JsonApi($client)) + ->throwInvalidApiKeyErrors() + ->raw() + ->fetch(); + + $this->assertSame(401, $response->raw()->getStatusCode()); + $this->assertSame(['code' => 'rate_limited', 'message' => 'Too many requests'], $response->data()); + } +} diff --git a/tests/Integration/HookTest.php b/tests/Integration/HookTest.php new file mode 100644 index 0000000..8b9d1e5 --- /dev/null +++ b/tests/Integration/HookTest.php @@ -0,0 +1,104 @@ +mockClient(new Response(body: '{"ok":false}')); + + (new JsonApi($client)) + ->beforeRequest(fn (RequestContext $context) => $context->request()->withHeader('X-Hook', 'before')) + ->raw() + ->fetch(); + + $this->assertSame('before', $client->getLastRequest()->getHeaderLine('X-Hook')); + } + + public function testAfterResponseHookCanReplaceResponse(): void + { + $client = $this->mockClient(new Response(body: '{"ok":false}')); + + $response = (new JsonApi($client)) + ->afterResponse(fn (ResponseContext $context) => new Response(status: 202, body: '{"ok":true}')) + ->raw() + ->fetch(); + + $this->assertSame(202, $response->raw()->getStatusCode()); + $this->assertSame(['ok' => true], $response->data()); + } + + public function testHookReturningNullLeavesObjectUnchanged(): void + { + $client = $this->mockClient(new Response(body: '{"ok":false}')); + + $response = (new JsonApi($client)) + ->beforeRequest(fn (RequestContext $context) => null) + ->afterResponse(fn (ResponseContext $context) => null) + ->raw() + ->fetch(); + + $this->assertFalse($client->getLastRequest()->hasHeader('X-Hook')); + $this->assertSame(200, $response->raw()->getStatusCode()); + $this->assertSame(['ok' => false], $response->data()); + } + + public function testHooksRunByPriorityAndInsertionOrder(): void + { + $client = $this->mockClient(new Response(body: '{"ok":false}')); + + (new JsonApi($client)) + ->beforeRequest(fn (RequestContext $context) => $context->request()->withAddedHeader('X-Hook-Order', 'low'), priority: 10) + ->beforeRequest(fn (RequestContext $context) => $context->request()->withAddedHeader('X-Hook-Order', 'first'), priority: 20) + ->beforeRequest(fn (RequestContext $context) => $context->request()->withAddedHeader('X-Hook-Order', 'second'), priority: 20) + ->raw() + ->fetch(); + + $this->assertSame(['first', 'second', 'low'], $client->getLastRequest()->getHeader('X-Hook-Order')); + } + + public function testHooksCanReadSdkConfig(): void + { + $client = $this->mockClient(new Response(body: '{"ok":false}')); + + $api = new JsonApi($client); + $api->config(['tenant' => 'acme']); + + $api + ->beforeRequest(fn (RequestContext $context) => $context->request()->withHeader('X-Tenant', $context->apiContext()->config()->get('tenant'))) + ->raw() + ->fetch(); + + $this->assertSame('acme', $client->getLastRequest()->getHeaderLine('X-Tenant')); + } + + public function testBeforeRequestHookRejectsInvalidReturnValue(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Before request hooks must return a RequestInterface instance or null.'); + + (new JsonApi($this->mockClient(new Response(body: '{"ok":false}')))) + ->beforeRequest(fn (RequestContext $context) => 'invalid') + ->raw() + ->fetch(); + } + + public function testAfterResponseHookRejectsInvalidReturnValue(): void + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('After response hooks must return a ResponseInterface instance or null.'); + + (new JsonApi($this->mockClient(new Response(body: '{"ok":false}')))) + ->afterResponse(fn (ResponseContext $context) => 'invalid') + ->raw() + ->fetch(); + } +} diff --git a/tests/Integration/PluginTest.php b/tests/Integration/PluginTest.php new file mode 100644 index 0000000..54680b4 --- /dev/null +++ b/tests/Integration/PluginTest.php @@ -0,0 +1,93 @@ +mockClient(); + + (new JsonApi($client)) + ->usePlugin($this->headerPlugin('low'), priority: 8) + ->usePlugin($this->headerPlugin('high'), priority: 40) + ->usePlugin($this->headerPlugin('middle'), priority: 16) + ->raw() + ->fetch(); + + $this->assertSame(['high', 'middle', 'low'], $client->getLastRequest()->getHeader('X-Plugin-Order')); + } + + public function testSdkUserCanConfigurePlugins(): void + { + $client = $this->mockClient(); + $api = new JsonApi($client); + + $api->setup()->plugins()->add($this->headerPlugin('user'), priority: 20); + $api->raw()->fetch(); + + $this->assertSame(['user'], $client->getLastRequest()->getHeader('X-Plugin-Order')); + } + + public function testConfiguredPluginsWithSamePriorityAreAppliedInInsertionOrder(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->usePlugin($this->headerPlugin('first'), priority: 16) + ->usePlugin($this->headerPlugin('second'), priority: 16) + ->raw() + ->fetch(); + + $this->assertSame(['first', 'second'], $client->getLastRequest()->getHeader('X-Plugin-Order')); + } + + public function testPluginPriorityCanTargetInternalAuthOrder(): void + { + $client = $this->mockClient(); + + (new JsonApi($client)) + ->useBearerAuth('secret') + ->usePlugin($this->authStatePlugin('before-auth'), priority: 35) + ->usePlugin($this->authStatePlugin('after-auth'), priority: 25) + ->raw() + ->fetch(); + + $this->assertSame( + ['before-auth:missing', 'after-auth:present'], + $client->getLastRequest()->getHeader('X-Auth-State') + ); + } + + public function testConfiguredPluginsAreNotDuplicatedAcrossRequests(): void + { + $client = $this->mockClient( + new Response(body: '{}'), + new Response(body: '{}') + ); + $api = (new JsonApi($client))->usePlugin($this->headerPlugin('once'), priority: 16); + + $api->raw()->fetch(); + $api->raw()->fetch(); + + $this->assertSame(['once'], $client->getRequests()[0]->getHeader('X-Plugin-Order')); + $this->assertSame(['once'], $client->getRequests()[1]->getHeader('X-Plugin-Order')); + } + + private function headerPlugin(string $value): Plugin + { + return new HeaderPlugin('X-Plugin-Order', $value, append: true); + } + + private function authStatePlugin(string $label): Plugin + { + return new AuthStatePlugin($label); + } +} diff --git a/tests/Integration/ResourceTest.php b/tests/Integration/ResourceTest.php new file mode 100644 index 0000000..1764368 --- /dev/null +++ b/tests/Integration/ResourceTest.php @@ -0,0 +1,274 @@ +client = new Client(); + $this->api = new FakeApi($this->client); + } + + public function testResourceGetMapsEntity(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $user = $this->api->users()->find(1); + + $this->assertInstanceOf(User::class, $user); + $this->assertSame(1, $user->getId()); + $this->assertSame('John', $user->getName()); + $this->assertSame('UTC', $user->getTimezone()); + $this->assertSame('https://api.example.com/users/1?locale=en', (string) $this->client->getLastRequest()->getUri()); + } + + /** + * @dataProvider resourceVerbProvider + */ + public function testResourceCanSendHttpVerbs(string $verb): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->sendWithVerb($verb); + + $this->assertSame($verb, $this->client->getLastRequest()->getMethod()); + } + + public function testResourceCanSendJsonBody(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->createWithJson(['name' => 'John']); + + $request = $this->client->getLastRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); + $this->assertSame('{"name":"John"}', (string) $request->getBody()); + } + + public function testResourceCanSendFormBody(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->createWithForm(['name' => 'John Doe']); + + $request = $this->client->getLastRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + $this->assertSame('name=John+Doe', (string) $request->getBody()); + } + + public function testResourceCanSendRawStringBody(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->createWithBody('raw-body'); + + $request = $this->client->getLastRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('raw-body', (string) $request->getBody()); + } + + public function testResourceCanSendStreamBody(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $stream = Stream::create('stream-body'); + + $this->api->users()->createWithBody($stream); + + $request = $this->client->getLastRequest(); + + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('stream-body', (string) $request->getBody()); + } + + public function testEndpointCanSetRequestQueryAndHeaders(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->findWithEndpointOptions(1); + + $request = $this->client->getLastRequest(); + + $this->assertSame('https://api.example.com/users/1?locale=en&active=1', (string) $request->getUri()); + $this->assertSame('acme', $request->getHeaderLine('X-Tenant')); + } + + public function testResourceBodyRejectsArrayData(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Use json() or form() to send array request data.'); + + $this->api->users()->createWithInvalidBody(['name' => 'John']); + } + + public function testResourcePathParametersAreEncoded(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api->users()->find('john/doe'); + + $this->assertSame('https://api.example.com/users/john%2Fdoe?locale=en', (string) $this->client->getLastRequest()->getUri()); + } + + public function testEndpointOptionsDoNotLeakBetweenResourceCalls(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + $this->client->addResponse(new Response(body: '{"id":2,"name":"Jane"}')); + + $users = $this->api->users(); + + $users->findWithActive(1); + $users->find(2); + + $requests = $this->client->getRequests(); + + $this->assertSame('https://api.example.com/users/1?locale=en&active=1', (string) $requests[0]->getUri()); + $this->assertSame('https://api.example.com/users/2?locale=en', (string) $requests[1]->getUri()); + } + + public function testSdkSpecificResourceChainCanSetQueryOptions(): void + { + $this->client->addResponse(new Response(body: '{"data":[{"id":1,"name":"John"}]}')); + + $this->api + ->users() + ->withStatus('active') + ->all(); + + $this->assertSame('https://api.example.com/users?locale=en&status=active', (string) $this->client->getLastRequest()->getUri()); + } + + public function testSdkSpecificResourceChainDoesNotLeakBetweenResourceCalls(): void + { + $this->client->addResponse(new Response(body: '{"data":[{"id":1,"name":"John"}]}')); + $this->client->addResponse(new Response(body: '{"data":[{"id":2,"name":"Jane"}]}')); + + $users = $this->api->users(); + + $users->withStatus('active')->all(); + $users->all(); + + $requests = $this->client->getRequests(); + + $this->assertSame('https://api.example.com/users?locale=en&status=active', (string) $requests[0]->getUri()); + $this->assertSame('https://api.example.com/users?locale=en', (string) $requests[1]->getUri()); + } + + public function testEndpointQueryOverridesGlobalDefaults(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api + ->users() + ->findWithEndpointLocale(1, 'pt'); + + $this->assertSame('https://api.example.com/users/1?locale=pt', (string) $this->client->getLastRequest()->getUri()); + } + + public function testResourceCanReadSdkConfig(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api + ->users() + ->findWithConfiguredTimezone(1); + + $this->assertSame('https://api.example.com/users/1?locale=en&timezone=UTC', (string) $this->client->getLastRequest()->getUri()); + } + + public function testResourceCreatedBeforeSetupChangeUsesLatestRequestDefaults(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $users = $this->api->users(); + + $this->api->setup()->defaultQuery('units', 'metric'); + + $users->find(1); + + $this->assertSame('https://api.example.com/users/1?locale=en&units=metric', (string) $this->client->getLastRequest()->getUri()); + } + + public function testNullQueryValuesAreOmitted(): void + { + $this->client->addResponse(new Response(body: '{"id":1,"name":"John"}')); + + $this->api + ->users() + ->findWithEmptyQuery(1); + + $this->assertSame('https://api.example.com/users/1?locale=en', (string) $this->client->getLastRequest()->getUri()); + } + + public function testEntityCanBeMappedFromResponseKey(): void + { + $this->client->addResponse(new Response(body: '{"data":{"id":1,"name":"John"}}')); + + $user = $this->api->users()->findFromEnvelope(1); + + $this->assertSame(1, $user->getId()); + $this->assertSame('John', $user->getName()); + } + + public function testCollectionCanBeMappedFromResponseKey(): void + { + $this->client->addResponse(new Response(body: '{"data":[{"id":1,"name":"John"},{"id":2,"name":"Jane"}]}')); + + $users = $this->api->users()->all(); + + $this->assertContainsOnlyInstancesOf(User::class, $users); + $this->assertSame(1, $users[0]->getId()); + $this->assertSame('John', $users[0]->getName()); + $this->assertSame(2, $users[1]->getId()); + $this->assertSame('Jane', $users[1]->getName()); + } + + public function testResponseCanBeMappedToEnvelope(): void + { + $this->client->addResponse(new Response(status: 202, body: '{"data":{"id":1,"name":"John"}}')); + + $envelope = $this->api->users()->findEnvelope(1); + + $this->assertInstanceOf(UserEnvelope::class, $envelope); + $this->assertSame(202, $envelope->getStatusCode()); + $this->assertSame('UTC', $envelope->getTimezone()); + $this->assertSame(1, $envelope->getUser()->getId()); + $this->assertSame('John', $envelope->getUser()->getName()); + $this->assertSame('UTC', $envelope->getUser()->getTimezone()); + } + + public static function resourceVerbProvider(): array + { + return [ + 'get' => ['GET'], + 'post' => ['POST'], + 'put' => ['PUT'], + 'patch' => ['PATCH'], + 'delete' => ['DELETE'], + 'head' => ['HEAD'], + 'options' => ['OPTIONS'], + 'connect' => ['CONNECT'], + 'trace' => ['TRACE'], + ]; + } +} diff --git a/tests/Integration/ResponseDecodingTest.php b/tests/Integration/ResponseDecodingTest.php new file mode 100644 index 0000000..a1c6767 --- /dev/null +++ b/tests/Integration/ResponseDecodingTest.php @@ -0,0 +1,116 @@ +mockClient(new Response(body: '{"id":1,"name":"John"}')); + + $response = (new PlainApi($client))->raw()->fetch(); + + $this->assertSame('{"id":1,"name":"John"}', $response->data()); + } + + public function testResponseDataIsDecodedWhenJsonDecodingIsEnabled(): void + { + $client = $this->mockClient(new Response(body: '{"id":1,"name":"John"}')); + + $response = (new JsonApi($client))->raw()->fetch(); + + $this->assertSame(['id' => 1, 'name' => 'John'], $response->data()); + } + + public function testEmptyJsonResponseBodyDecodesToNull(): void + { + $client = $this->mockClient(new Response(body: '')); + + $response = (new JsonApi($client))->raw()->fetch(); + + $this->assertNull($response->data()); + } + + public function testInvalidJsonThrowsWhenJsonDecodingIsEnabled(): void + { + $client = $this->mockClient(new Response(body: '{invalid-json')); + + $this->expectException(\JsonException::class); + + (new JsonApi($client))->raw()->fetch(); + } + + public function testResponseDataIsDecodedWhenXmlDecodingIsEnabled(): void + { + $client = $this->mockClient(new Response(body: '1John')); + + $response = (new XmlApi($client))->raw()->fetch(); + + $this->assertInstanceOf(SimpleXMLElement::class, $response->data()); + $this->assertSame('1', (string) $response->data()->id); + $this->assertSame('John', (string) $response->data()->name); + } + + public function testEmptyXmlResponseBodyDecodesToNull(): void + { + $client = $this->mockClient(new Response(body: '')); + + $response = (new XmlApi($client))->raw()->fetch(); + + $this->assertNull($response->data()); + } + + public function testInvalidXmlThrowsWhenXmlDecodingIsEnabled(): void + { + $client = $this->mockClient(new Response(body: 'expectException(\RuntimeException::class); + + (new XmlApi($client))->raw()->fetch(); + } + + public function testCustomDecoderRunsThroughApiResourcePipeline(): void + { + $client = $this->mockClient(new Response(status: 202, body: 'accepted')); + + $response = (new PlainApi($client)) + ->decodeWith(fn (ResponseInterface $response): array => [ + 'status' => $response->getStatusCode(), + 'body' => (string) $response->getBody(), + ]) + ->raw() + ->fetch(); + + $this->assertSame([ + 'status' => 202, + 'body' => 'accepted', + ], $response->data()); + } + + public function testResourceCreatedBeforeSetupChangeUsesLatestResponseDecoder(): void + { + $client = $this->mockClient(new Response(status: 202, body: 'accepted')); + $api = new PlainApi($client); + $resource = $api->raw(); + + $api->decodeWith(fn (ResponseInterface $response): array => [ + 'status' => $response->getStatusCode(), + 'body' => (string) $response->getBody(), + ]); + + $response = $resource->fetch(); + + $this->assertSame([ + 'status' => 202, + 'body' => 'accepted', + ], $response->data()); + } +} diff --git a/tests/Support/AbstractTestCase.php b/tests/Support/AbstractTestCase.php new file mode 100644 index 0000000..a23babe --- /dev/null +++ b/tests/Support/AbstractTestCase.php @@ -0,0 +1,29 @@ +addResponse($response); + } + + return $client; + } + + protected function queryFromLastRequest(Client $client): array + { + parse_str($client->getLastRequest()->getUri()->getQuery(), $query); + + return $query; + } +} diff --git a/tests/Unit/Builder/AuthBuilderTest.php b/tests/Unit/Builder/AuthBuilderTest.php new file mode 100644 index 0000000..6f7e2cd --- /dev/null +++ b/tests/Unit/Builder/AuthBuilderTest.php @@ -0,0 +1,120 @@ +assertNull((new AuthBuilder())->getAuthentication()); + } + + public function testSingleAuthenticationIsReturnedDirectly(): void + { + $authentication = (new AuthBuilder()) + ->bearer('token') + ->getAuthentication(); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com')); + + $this->assertSame('Bearer token', $request->getHeaderLine('Authorization')); + } + + public function testAuthenticationHelpersReplacePreviousAuthentication(): void + { + $authentication = (new AuthBuilder()) + ->bearer('token') + ->query('appid', 'key') + ->getAuthentication(); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('', $request->getHeaderLine('Authorization')); + $this->assertSame('appid=key', $request->getUri()->getQuery()); + } + + public function testExplicitChainComposesHttplugAuthenticationObjects(): void + { + $authentication = (new AuthBuilder()) + ->chain( + new Header('X-Api-Key', 'secret'), + new Header('X-Second-Auth', 'second') + ) + ->getAuthentication(); + + $this->assertInstanceOf(Chain::class, $authentication); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('secret', $request->getHeaderLine('X-Api-Key')); + $this->assertSame('second', $request->getHeaderLine('X-Second-Auth')); + } + + public function testUseReplacesAuthenticationWithHttplugAuthenticationObject(): void + { + $authentication = (new AuthBuilder()) + ->bearer('token') + ->use(new Header('X-Api-Key', 'secret')) + ->getAuthentication(); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('', $request->getHeaderLine('Authorization')); + $this->assertSame('secret', $request->getHeaderLine('X-Api-Key')); + } + + public function testWsseAuthenticationAddsWsseHeaders(): void + { + $authentication = (new AuthBuilder()) + ->wsse('user', 'pass') + ->getAuthentication(); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('WSSE profile="UsernameToken"', $request->getHeaderLine('Authorization')); + $this->assertStringContainsString('UsernameToken Username="user"', $request->getHeaderLine('X-WSSE')); + } + + public function testConditionalAuthenticationOnlyAppliesWhenMatched(): void + { + $authentication = (new AuthBuilder()) + ->conditional(new RequestMatcher(path: '^/admin'), new Header('X-Admin-Auth', 'secret')) + ->getAuthentication(); + + $matched = $authentication->authenticate(new Request('GET', 'https://api.example.com/admin/users')); + $unmatched = $authentication->authenticate(new Request('GET', 'https://api.example.com/users')); + + $this->assertSame('secret', $matched->getHeaderLine('X-Admin-Auth')); + $this->assertSame('', $unmatched->getHeaderLine('X-Admin-Auth')); + } + + public function testCustomAuthenticationUsesCallback(): void + { + $authentication = (new AuthBuilder()) + ->custom(fn(Request $request) => $request->withHeader('X-Custom-Auth', 'custom')) + ->getAuthentication(); + + $request = $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + + $this->assertSame('custom', $request->getHeaderLine('X-Custom-Auth')); + } + + public function testCustomAuthenticationCallbackMustReturnRequest(): void + { + $authentication = (new AuthBuilder()) + ->custom(fn() => null) + ->getAuthentication(); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Custom authentication callback must return a PSR-7 request.'); + + $authentication->authenticate(new Request('GET', 'https://api.example.com/weather')); + } +} diff --git a/tests/Unit/Builder/CacheBuilderTest.php b/tests/Unit/Builder/CacheBuilderTest.php index 32d74f7..24019ec 100644 --- a/tests/Unit/Builder/CacheBuilderTest.php +++ b/tests/Unit/Builder/CacheBuilderTest.php @@ -3,54 +3,54 @@ namespace ProgrammatorDev\Api\Test\Unit\Builder; use ProgrammatorDev\Api\Builder\CacheBuilder; -use ProgrammatorDev\Api\Test\AbstractTestCase; +use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use Psr\Cache\CacheItemPoolInterface; class CacheBuilderTest extends AbstractTestCase { - public function testDefaults() + public function testCacheBuilderUsesDefaults(): void { $pool = $this->createMock(CacheItemPoolInterface::class); $cacheBuilder = new CacheBuilder($pool); $this->assertInstanceOf(CacheItemPoolInterface::class, $cacheBuilder->getPool()); - $this->assertSame(60, $cacheBuilder->getTtl()); + $this->assertSame(3600, $cacheBuilder->getDefaultTtl()); $this->assertSame(['GET', 'HEAD'], $cacheBuilder->getMethods()); $this->assertSame(['max-age'], $cacheBuilder->getResponseCacheDirectives()); } - public function testDependencyInjection() + public function testCacheBuilderAcceptsConstructorValues(): void { $pool = $this->createMock(CacheItemPoolInterface::class); - $ttl = 600; + $defaultTtl = 600; $methods = ['GET']; $responseCacheDirectives = ['no-cache', 'max-age']; - $cacheBuilder = new CacheBuilder($pool, $ttl, $methods, $responseCacheDirectives); + $cacheBuilder = new CacheBuilder($pool, $defaultTtl, $methods, $responseCacheDirectives); - $this->assertInstanceOf(CacheItemPoolInterface::class, $cacheBuilder->getPool()); - $this->assertSame($ttl, $cacheBuilder->getTtl()); + $this->assertSame($pool, $cacheBuilder->getPool()); + $this->assertSame($defaultTtl, $cacheBuilder->getDefaultTtl()); $this->assertSame($methods, $cacheBuilder->getMethods()); $this->assertSame($responseCacheDirectives, $cacheBuilder->getResponseCacheDirectives()); } - public function testSetters() + public function testCacheBuilderCanBeConfiguredFluently(): void { $pool = $this->createMock(CacheItemPoolInterface::class); - $ttl = 600; + $defaultTtl = 600; $methods = ['GET']; $responseCacheDirectives = ['no-cache', 'max-age']; - $cacheBuilder = new CacheBuilder($pool); - $cacheBuilder->setPool($pool); - $cacheBuilder->setTtl($ttl); - $cacheBuilder->setMethods($methods); - $cacheBuilder->setResponseCacheDirectives($responseCacheDirectives); + $cacheBuilder = (new CacheBuilder($pool)) + ->pool($pool) + ->defaultTtl($defaultTtl) + ->methods($methods) + ->responseCacheDirectives($responseCacheDirectives); - $this->assertInstanceOf(CacheItemPoolInterface::class, $cacheBuilder->getPool()); - $this->assertSame($ttl, $cacheBuilder->getTtl()); + $this->assertSame($pool, $cacheBuilder->getPool()); + $this->assertSame($defaultTtl, $cacheBuilder->getDefaultTtl()); $this->assertSame($methods, $cacheBuilder->getMethods()); $this->assertSame($responseCacheDirectives, $cacheBuilder->getResponseCacheDirectives()); } -} \ No newline at end of file +} diff --git a/tests/Unit/Builder/ClientBuilderTest.php b/tests/Unit/Builder/ClientBuilderTest.php index 8f0b438..940652b 100644 --- a/tests/Unit/Builder/ClientBuilderTest.php +++ b/tests/Unit/Builder/ClientBuilderTest.php @@ -2,17 +2,15 @@ namespace ProgrammatorDev\Api\Test\Unit\Builder; -use Http\Client\Common\Plugin; use ProgrammatorDev\Api\Builder\ClientBuilder; -use ProgrammatorDev\Api\Exception\PluginException; -use ProgrammatorDev\Api\Test\AbstractTestCase; +use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; class ClientBuilderTest extends AbstractTestCase { - public function testDefaults() + public function testClientBuilderUsesDiscoveredDefaults(): void { $clientBuilder = new ClientBuilder(); @@ -21,7 +19,7 @@ public function testDefaults() $this->assertInstanceOf(StreamFactoryInterface::class, $clientBuilder->getStreamFactory()); } - public function testDependencyInjection() + public function testClientBuilderAcceptsConstructorValues(): void { $client = $this->createMock(ClientInterface::class); $requestFactory = $this->createMock(RequestFactoryInterface::class); @@ -30,56 +28,23 @@ public function testDependencyInjection() $clientBuilder = new ClientBuilder($client, $requestFactory, $streamFactory); $this->assertInstanceOf(ClientInterface::class, $clientBuilder->getClient()); - $this->assertInstanceOf(RequestFactoryInterface::class, $clientBuilder->getRequestFactory()); - $this->assertInstanceOf(StreamFactoryInterface::class, $clientBuilder->getStreamFactory()); + $this->assertSame($requestFactory, $clientBuilder->getRequestFactory()); + $this->assertSame($streamFactory, $clientBuilder->getStreamFactory()); } - public function testSetters() + public function testClientBuilderCanBeConfiguredFluently(): void { $client = $this->createMock(ClientInterface::class); $requestFactory = $this->createMock(RequestFactoryInterface::class); $streamFactory = $this->createMock(StreamFactoryInterface::class); - $clientBuilder = new ClientBuilder(); - $clientBuilder->setClient($client); - $clientBuilder->setRequestFactory($requestFactory); - $clientBuilder->setStreamFactory($streamFactory); + $clientBuilder = (new ClientBuilder()) + ->client($client) + ->requestFactory($requestFactory) + ->streamFactory($streamFactory); $this->assertInstanceOf(ClientInterface::class, $clientBuilder->getClient()); - $this->assertInstanceOf(RequestFactoryInterface::class, $clientBuilder->getRequestFactory()); - $this->assertInstanceOf(StreamFactoryInterface::class, $clientBuilder->getStreamFactory()); - } - - public function testAddPlugin() - { - $plugin = $this->createMock(Plugin::class); - $clientBuilder = new ClientBuilder(); - - $clientBuilder->addPlugin($plugin, 1); - $clientBuilder->addPlugin($plugin, 3); - $clientBuilder->addPlugin($plugin, 2); - - $this->assertCount(3, $clientBuilder->getPlugins()); - // plugins array keys are used as priority [priority => plugin] - // so check if the order of keys (priority) is sorted - $this->assertSame( - [ - 0 => 3, - 1 => 2, - 2 => 1 - ], - array_keys($clientBuilder->getPlugins()) - ); - } - - public function testAddPluginWithSamePriority() - { - $plugin = $this->createMock(Plugin::class); - $clientBuilder = new ClientBuilder(); - - $clientBuilder->addPlugin($plugin, 1); - $clientBuilder->addPlugin($plugin, 1); - - $this->assertCount(1, $clientBuilder->getPlugins()); + $this->assertSame($requestFactory, $clientBuilder->getRequestFactory()); + $this->assertSame($streamFactory, $clientBuilder->getStreamFactory()); } -} \ No newline at end of file +} diff --git a/tests/Unit/Builder/ErrorBuilderTest.php b/tests/Unit/Builder/ErrorBuilderTest.php new file mode 100644 index 0000000..05e52f2 --- /dev/null +++ b/tests/Unit/Builder/ErrorBuilderTest.php @@ -0,0 +1,134 @@ +expectNotToPerformAssertions(); + + $builder->status(404, fn(): \Throwable => new \RuntimeException('Not found')); + $builder->throwIfMatched($this->context(statusCode: 200)); + } + + public function testMatchedStatusThrowsConfiguredThrowable(): void + { + $builder = new ErrorBuilder(); + $builder->status(404, fn(ErrorContext $context): \Throwable => new \RuntimeException( + sprintf('Status %d in %s', $context->statusCode(), $context->apiContext()->config()->get('timezone')) + )); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Status 404 in UTC'); + + $builder->throwIfMatched($this->context(statusCode: 404)); + } + + public function testMatchedStatusThrowsConfiguredThrowableClass(): void + { + $builder = new ErrorBuilder(); + $builder->status(404, \RuntimeException::class); + + $this->expectException(\RuntimeException::class); + + $builder->throwIfMatched($this->context(statusCode: 404)); + } + + public function testMatchedStatusThrowsConfiguredThrowableFromStatusMap(): void + { + $builder = new ErrorBuilder(); + $builder->statuses([ + 401 => \UnexpectedValueException::class, + 404 => fn(): \Throwable => new \RuntimeException('Missing resource'), + ]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Missing resource'); + + $builder->throwIfMatched($this->context(statusCode: 404)); + } + + public function testStatusHandlerRequiresThrowableClass(): void + { + $builder = new ErrorBuilder(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Error handler for status 404 must be a Throwable class or callable.'); + + $builder->status(404, \stdClass::class); + } + + public function testStatusHandlerMustReturnThrowable(): void + { + $builder = new ErrorBuilder(); + $builder->status(404, fn(): string => 'invalid'); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Status error handler must return a Throwable.'); + + $builder->throwIfMatched($this->context(statusCode: 404)); + } + + public function testCustomHandlerThrowsWhenMatched(): void + { + $builder = new ErrorBuilder(); + $builder->when(function (ErrorContext $context): ?\Throwable { + if ($context->response()->data()['code'] !== 'invalid_api_key') { + return null; + } + + return new \RuntimeException('Invalid API key'); + }); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid API key'); + + $builder->throwIfMatched($this->context(statusCode: 401, data: ['code' => 'invalid_api_key'])); + } + + public function testCustomHandlerDoesNotThrowWhenNotMatched(): void + { + $builder = new ErrorBuilder(); + + $this->expectNotToPerformAssertions(); + + $builder->when(fn(): ?\Throwable => null); + + $builder->throwIfMatched($this->context(statusCode: 200)); + } + + public function testCustomHandlerMustReturnThrowableOrNull(): void + { + $builder = new ErrorBuilder(); + $builder->when(fn(): string => 'invalid'); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Error handler must return a Throwable or null.'); + + $builder->throwIfMatched($this->context(statusCode: 200)); + } + + /** + * @param array $data + */ + private function context(int $statusCode, array $data = []): ErrorContext + { + $context = new Context(new Config(['timezone' => 'UTC'])); + + return new ErrorContext( + response: new Response($data, new PsrResponse(status: $statusCode), $context), + context: $context + ); + } +} diff --git a/tests/Unit/Builder/Listener/CacheLoggerListenerTest.php b/tests/Unit/Builder/Listener/CacheLoggerListenerTest.php new file mode 100644 index 0000000..f881812 --- /dev/null +++ b/tests/Unit/Builder/Listener/CacheLoggerListenerTest.php @@ -0,0 +1,99 @@ +createMock(LoggerInterface::class); + $cacheItem = $this->cacheItem(); + + $logger + ->expects($this->once()) + ->method('info') + ->with( + $this->stringStartsWith('HTTP cache hit:'), + [ + 'cache_expires_at' => 1234567890, + 'cache_key' => 'cache-key', + ] + ); + + $listener = new CacheLoggerListener(new LoggerBuilder($logger)); + + $listener->onCacheResponse( + new Request('GET', 'https://api.example.com/users'), + new Response(body: '{}'), + true, + $cacheItem + ); + } + + public function testCachedResponseIsLogged(): void + { + $logger = $this->createMock(LoggerInterface::class); + $cacheItem = $this->cacheItem(); + + $logger + ->expects($this->once()) + ->method('info') + ->with( + $this->stringStartsWith('HTTP response cached:'), + [ + 'cache_expires_at' => 1234567890, + 'cache_key' => 'cache-key', + ] + ); + + $listener = new CacheLoggerListener(new LoggerBuilder($logger)); + + $listener->onCacheResponse( + new Request('GET', 'https://api.example.com/users'), + new Response(body: '{}'), + false, + $cacheItem + ); + } + + public function testCacheMissWithoutStoredResponseIsNotLogged(): void + { + $logger = $this->createMock(LoggerInterface::class); + + $logger + ->expects($this->never()) + ->method('info'); + + $listener = new CacheLoggerListener(new LoggerBuilder($logger)); + + $listener->onCacheResponse( + new Request('GET', 'https://api.example.com/users'), + new Response(body: '{}'), + false, + null + ); + } + + private function cacheItem(): CacheItemInterface + { + $cacheItem = $this->createMock(CacheItemInterface::class); + + $cacheItem + ->method('get') + ->willReturn(['expiresAt' => 1234567890]); + + $cacheItem + ->method('getKey') + ->willReturn('cache-key'); + + return $cacheItem; + } +} diff --git a/tests/Unit/Builder/LoggerBuilderTest.php b/tests/Unit/Builder/LoggerBuilderTest.php index f562b5d..018f7db 100644 --- a/tests/Unit/Builder/LoggerBuilderTest.php +++ b/tests/Unit/Builder/LoggerBuilderTest.php @@ -4,42 +4,42 @@ use Http\Message\Formatter; use ProgrammatorDev\Api\Builder\LoggerBuilder; -use ProgrammatorDev\Api\Test\AbstractTestCase; +use ProgrammatorDev\Api\Test\Support\AbstractTestCase; use Psr\Log\LoggerInterface; class LoggerBuilderTest extends AbstractTestCase { - public function testDefaults() + public function testLoggerBuilderUsesDefaults(): void { $logger = $this->createMock(LoggerInterface::class); $loggerBuilder = new LoggerBuilder($logger); - $this->assertInstanceOf(LoggerInterface::class, $loggerBuilder->getLogger()); + $this->assertSame($logger, $loggerBuilder->getLogger()); $this->assertInstanceOf(Formatter\SimpleFormatter::class, $loggerBuilder->getFormatter()); } - public function testDependencyInjection() + public function testLoggerBuilderAcceptsConstructorValues(): void { $logger = $this->createMock(LoggerInterface::class); $formatter = $this->createMock(Formatter::class); $loggerBuilder = new LoggerBuilder($logger, $formatter); - $this->assertInstanceOf(LoggerInterface::class, $loggerBuilder->getLogger()); - $this->assertInstanceOf(Formatter::class, $loggerBuilder->getFormatter()); + $this->assertSame($logger, $loggerBuilder->getLogger()); + $this->assertSame($formatter, $loggerBuilder->getFormatter()); } - public function testSetters() + public function testLoggerBuilderCanBeConfiguredFluently(): void { $logger = $this->createMock(LoggerInterface::class); $formatter = $this->createMock(Formatter::class); - $loggerBuilder = new LoggerBuilder($logger); - $loggerBuilder->setLogger($logger); - $loggerBuilder->setFormatter($formatter); + $loggerBuilder = (new LoggerBuilder($logger)) + ->logger($logger) + ->formatter($formatter); - $this->assertInstanceOf(LoggerInterface::class, $loggerBuilder->getLogger()); - $this->assertInstanceOf(Formatter::class, $loggerBuilder->getFormatter()); + $this->assertSame($logger, $loggerBuilder->getLogger()); + $this->assertSame($formatter, $loggerBuilder->getFormatter()); } -} \ No newline at end of file +} diff --git a/tests/Unit/Builder/PluginBuilderTest.php b/tests/Unit/Builder/PluginBuilderTest.php new file mode 100644 index 0000000..2905401 --- /dev/null +++ b/tests/Unit/Builder/PluginBuilderTest.php @@ -0,0 +1,53 @@ +createMock(Plugin::class); + $high = $this->createMock(Plugin::class); + $middle = $this->createMock(Plugin::class); + + $plugins = (new PluginBuilder()) + ->add($low, priority: 8) + ->add($high, priority: 40) + ->add($middle, priority: 16) + ->getPlugins(); + + $this->assertSame([$high, $middle, $low], $plugins); + } + + public function testPluginsWithSamePriorityArePreservedInInsertionOrder(): void + { + $first = $this->createMock(Plugin::class); + $second = $this->createMock(Plugin::class); + + $plugins = (new PluginBuilder()) + ->add($first, priority: 16) + ->add($second, priority: 16) + ->getPlugins(); + + $this->assertSame([$first, $second], $plugins); + } + + public function testPluginBuildersCanBeMergedWithoutLosingPriority(): void + { + $low = $this->createMock(Plugin::class); + $high = $this->createMock(Plugin::class); + + $source = (new PluginBuilder())->add($high, priority: 40); + + $plugins = (new PluginBuilder()) + ->add($low, priority: 8) + ->merge($source) + ->getPlugins(); + + $this->assertSame([$high, $low], $plugins); + } +} diff --git a/tests/Unit/ConfigTest.php b/tests/Unit/ConfigTest.php new file mode 100644 index 0000000..9f54e29 --- /dev/null +++ b/tests/Unit/ConfigTest.php @@ -0,0 +1,62 @@ + 'UTC']); + + $this->assertTrue($config->has('timezone')); + $this->assertSame('UTC', $config->get('timezone')); + $this->assertSame(['timezone' => 'UTC'], $config->all()); + } + + public function testConfigReturnsDefaultForMissingValue(): void + { + $config = new Config(); + + $this->assertFalse($config->has('timezone')); + $this->assertSame('Europe/Lisbon', $config->get('timezone', 'Europe/Lisbon')); + } + + public function testConfigCanStoreNullValues(): void + { + $config = new Config(['timezone' => null]); + + $this->assertTrue($config->has('timezone')); + $this->assertNull($config->get('timezone', 'Europe/Lisbon')); + } + + public function testConfigCanBeUpdated(): void + { + $config = new Config(['timezone' => 'UTC']); + + $config + ->set('timezone', 'Europe/Lisbon') + ->merge(['units' => 'metric']); + + $this->assertSame([ + 'timezone' => 'Europe/Lisbon', + 'units' => 'metric', + ], $config->all()); + } + + public function testConfigCanReturnOnlySelectedValues(): void + { + $config = new Config([ + 'timezone' => 'UTC', + 'units' => 'metric', + 'internal' => true, + ]); + + $this->assertSame([ + 'timezone' => 'UTC', + 'units' => 'metric', + ], $config->only('timezone', 'units', 'missing')); + } +} diff --git a/tests/Unit/Context/ErrorContextTest.php b/tests/Unit/Context/ErrorContextTest.php new file mode 100644 index 0000000..a75b134 --- /dev/null +++ b/tests/Unit/Context/ErrorContextTest.php @@ -0,0 +1,24 @@ + 'UTC'])); + $response = new Response([], new PsrResponse(status: 404), $context); + $errorContext = new ErrorContext($response, $context); + + $this->assertSame($response, $errorContext->response()); + $this->assertSame($context, $errorContext->apiContext()); + $this->assertSame(404, $errorContext->statusCode()); + } +} diff --git a/tests/Unit/ContextTest.php b/tests/Unit/ContextTest.php new file mode 100644 index 0000000..94ebbb4 --- /dev/null +++ b/tests/Unit/ContextTest.php @@ -0,0 +1,26 @@ +assertFalse($context->config()->has('timezone')); + } + + public function testContextReturnsProvidedConfig(): void + { + $config = new Config(['timezone' => 'UTC']); + $context = new Context($config); + + $this->assertSame($config, $context->config()); + $this->assertSame('UTC', $context->config()->get('timezone')); + } +} diff --git a/tests/Unit/Helper/StringHelperTest.php b/tests/Unit/Helper/StringHelperTest.php deleted file mode 100644 index 706c8de..0000000 --- a/tests/Unit/Helper/StringHelperTest.php +++ /dev/null @@ -1,17 +0,0 @@ -assertSame( - 'https://example.com/path/test', - StringHelper::reduceDuplicateSlashes('https://example.com////path//test') - ); - } -} \ No newline at end of file diff --git a/tests/Unit/Helper/UrlHelperTest.php b/tests/Unit/Helper/UrlHelperTest.php new file mode 100644 index 0000000..53272b1 --- /dev/null +++ b/tests/Unit/Helper/UrlHelperTest.php @@ -0,0 +1,64 @@ +assertSame($expected, UrlHelper::join($baseUrl, $path)); + } + + public static function urlProvider(): array + { + return [ + 'base trailing slash and path leading slash' => [ + 'https://api.example.com/', + '/users', + 'https://api.example.com/users', + ], + 'base without trailing slash and path without leading slash' => [ + 'https://api.example.com', + 'users', + 'https://api.example.com/users', + ], + 'base path is preserved' => [ + 'https://api.example.com/v1/', + '/users', + 'https://api.example.com/v1/users', + ], + 'duplicate path slashes are reduced' => [ + 'https://api.example.com/', + '//users//1', + 'https://api.example.com/users/1', + ], + 'absolute URL overrides base URL' => [ + 'https://api.example.com', + 'https://other.example.com/users', + 'https://other.example.com/users', + ], + 'scheme slashes are preserved' => [ + null, + 'https://api.example.com//users', + 'https://api.example.com//users', + ], + 'relative path works without base URL' => [ + null, + '/users//1', + '/users/1', + ], + ]; + } + + public function testIsAbsoluteUrl(): void + { + $this->assertTrue(UrlHelper::isAbsoluteUrl('https://api.example.com/users')); + $this->assertFalse(UrlHelper::isAbsoluteUrl('/users')); + } +} diff --git a/tests/Unit/PipelineOptionsTest.php b/tests/Unit/PipelineOptionsTest.php new file mode 100644 index 0000000..1b18137 --- /dev/null +++ b/tests/Unit/PipelineOptionsTest.php @@ -0,0 +1,45 @@ +values[] = $value; + } + }; + + $options = (new PipelineOptions()) + ->withOverride('feature', fn(object $builder) => $builder->add('override')) + ->withDefault('feature', fn(object $builder) => $builder->add('default')); + + $this->assertTrue($options->has('feature')); + $this->assertFalse($options->has('other')); + + $this->assertSame($builder, $options->applyTo('feature', $builder)); + $this->assertSame(['default', 'override'], $builder->values); + } + + public function testPipelineOptionsIgnoreUnrelatedKeys(): void + { + $builder = new class { + public array $values = []; + }; + + $options = (new PipelineOptions()) + ->withDefault('feature', fn(object $builder) => $builder->values[] = 'feature'); + + $options->applyTo('other', $builder); + + $this->assertSame([], $builder->values); + } +} diff --git a/tests/Unit/ResponseDecoderTest.php b/tests/Unit/ResponseDecoderTest.php new file mode 100644 index 0000000..fb55a05 --- /dev/null +++ b/tests/Unit/ResponseDecoderTest.php @@ -0,0 +1,104 @@ +assertSame('{"ok":true}', $decoder->decode(new Response(body: '{"ok":true}'))); + } + + public function testDecodeReturnsArrayWhenJsonDecodingIsEnabled(): void + { + $builder = (new ResponseBuilder())->json(); + $decoder = new ResponseDecoder($builder); + + $this->assertSame(['ok' => true], $decoder->decode(new Response(body: '{"ok":true}'))); + } + + public function testDecodeReturnsNullForEmptyJsonBody(): void + { + $builder = (new ResponseBuilder())->json(); + $decoder = new ResponseDecoder($builder); + + $this->assertNull($decoder->decode(new Response(body: ''))); + } + + public function testDecodeThrowsForInvalidJsonBody(): void + { + $builder = (new ResponseBuilder())->json(); + $decoder = new ResponseDecoder($builder); + + $this->expectException(\JsonException::class); + + $decoder->decode(new Response(body: '{invalid-json')); + } + + public function testDecodeReturnsXmlElementWhenXmlDecodingIsEnabled(): void + { + $builder = (new ResponseBuilder())->xml(); + $decoder = new ResponseDecoder($builder); + + $data = $decoder->decode(new Response(body: '1John')); + + $this->assertInstanceOf(SimpleXMLElement::class, $data); + $this->assertSame('1', (string) $data->id); + $this->assertSame('John', (string) $data->name); + } + + public function testDecodeReturnsNullForEmptyXmlBody(): void + { + $builder = (new ResponseBuilder())->xml(); + $decoder = new ResponseDecoder($builder); + + $this->assertNull($decoder->decode(new Response(body: ''))); + } + + public function testDecodeThrowsForInvalidXmlBody(): void + { + $builder = (new ResponseBuilder())->xml(); + $decoder = new ResponseDecoder($builder); + + $this->expectException(RuntimeException::class); + + $decoder->decode(new Response(body: 'custom(function (Response $response): array { + return [ + 'status' => $response->getStatusCode(), + 'body' => (string) $response->getBody(), + ]; + }); + + $decoder = new ResponseDecoder($builder); + + $this->assertSame([ + 'status' => 202, + 'body' => 'accepted', + ], $decoder->decode(new Response(status: 202, body: 'accepted'))); + } + + public function testChangingFormatClearsCustomDecoder(): void + { + $builder = (new ResponseBuilder()) + ->custom(fn (Response $response): string => 'custom') + ->raw(); + + $decoder = new ResponseDecoder($builder); + + $this->assertSame('raw-body', $decoder->decode(new Response(body: 'raw-body'))); + } +} diff --git a/tests/Unit/ResponseTest.php b/tests/Unit/ResponseTest.php new file mode 100644 index 0000000..c7cf2cc --- /dev/null +++ b/tests/Unit/ResponseTest.php @@ -0,0 +1,231 @@ + 1, 'name' => 'John']; + $response = new Response($data, new PsrResponse()); + + $this->assertSame($data, $response->data()); + } + + public function testRawReturnsPsrResponse(): void + { + $rawResponse = new PsrResponse(status: 202); + $response = new Response(['id' => 1], $rawResponse); + + $this->assertSame($rawResponse, $response->raw()); + } + + public function testEnvelopeReceivesEmptyContextByDefault(): void + { + $response = new Response(['data' => ['id' => 1, 'name' => 'John']], new PsrResponse()); + + $envelope = $response->envelope(UserEnvelope::class); + + $this->assertNull($envelope->getTimezone()); + } + + public function testEnvelopeReceivesContext(): void + { + $config = new Config(['timezone' => 'UTC']); + $context = new Context($config); + $response = new Response(['data' => ['id' => 1, 'name' => 'John']], new PsrResponse(), $context); + + $envelope = $response->envelope(UserEnvelope::class); + + $this->assertSame('UTC', $envelope->getTimezone()); + } + + public function testEntityReceivesContext(): void + { + $context = new Context(new Config(['timezone' => 'UTC'])); + $response = new Response(['id' => 1, 'name' => 'John'], new PsrResponse(), $context); + + $user = $response->entity(User::class); + + $this->assertSame('UTC', $user->getTimezone()); + } + + public function testCollectionReceivesContext(): void + { + $context = new Context(new Config(['timezone' => 'UTC'])); + $response = new Response([ + ['id' => 1, 'name' => 'John'], + ], new PsrResponse(), $context); + + $users = $response->collection(User::class); + + $this->assertSame('UTC', $users[0]->getTimezone()); + } + + public function testEntityCanBeMappedFromRootData(): void + { + $response = new Response(['id' => 1, 'name' => 'John'], new PsrResponse()); + + $user = $response->entity(User::class); + + $this->assertSame(1, $user->getId()); + $this->assertSame('John', $user->getName()); + } + + public function testCollectionCanBeMappedFromRootData(): void + { + $response = new Response([ + ['id' => 1, 'name' => 'John'], + ['id' => 2, 'name' => 'Jane'], + ], new PsrResponse()); + + $users = $response->collection(User::class); + + $this->assertContainsOnlyInstancesOf(User::class, $users); + $this->assertSame(1, $users[0]->getId()); + $this->assertSame('John', $users[0]->getName()); + $this->assertSame(2, $users[1]->getId()); + $this->assertSame('Jane', $users[1]->getName()); + } + + public function testCollectionCanMapEmptyData(): void + { + $response = new Response([], new PsrResponse()); + + $this->assertSame([], $response->collection(User::class)); + } + + public function testCollectionPreservesKeys(): void + { + $response = new Response([ + 'admin' => ['id' => 1, 'name' => 'John'], + 'user' => ['id' => 2, 'name' => 'Jane'], + ], new PsrResponse()); + + $users = $response->collection(User::class); + + $this->assertSame(['admin', 'user'], array_keys($users)); + $this->assertSame('John', $users['admin']->getName()); + $this->assertSame('Jane', $users['user']->getName()); + } + + public function testEntityRejectsClassThatDoesNotImplementEntity(): void + { + $response = new Response(['id' => 1], new PsrResponse()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must implement'); + + $response->entity(\stdClass::class); + } + + public function testCollectionRejectsClassThatDoesNotImplementEntity(): void + { + $response = new Response([['id' => 1]], new PsrResponse()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must implement'); + + $response->collection(\stdClass::class); + } + + public function testEnvelopeRejectsClassThatDoesNotImplementEnvelope(): void + { + $response = new Response(['data' => ['id' => 1]], new PsrResponse()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('must implement'); + + $response->envelope(\stdClass::class); + } + + public function testEntityRejectsNonArrayData(): void + { + $response = new Response('not-array', new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Entity data must be an array.'); + + $response->entity(User::class); + } + + public function testEntityRejectsNonArrayDataFromKey(): void + { + $response = new Response(['data' => 'not-array'], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Entity data must be an array.'); + + $response->entity(User::class, key: 'data'); + } + + public function testEntityRejectsMissingDataKey(): void + { + $response = new Response(['id' => 1], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Response data key "data" does not exist.'); + + $response->entity(User::class, key: 'data'); + } + + public function testCollectionRejectsNonArrayData(): void + { + $response = new Response('not-array', new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Collection data must be an array.'); + + $response->collection(User::class); + } + + public function testCollectionRejectsNonArrayDataFromKey(): void + { + $response = new Response(['data' => 'not-array'], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Collection data must be an array.'); + + $response->collection(User::class, key: 'data'); + } + + public function testCollectionRejectsMissingDataKey(): void + { + $response = new Response(['items' => []], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Response data key "data" does not exist.'); + + $response->collection(User::class, key: 'data'); + } + + public function testCollectionRejectsNonArrayItems(): void + { + $response = new Response(['data' => ['not-array-item']], new PsrResponse()); + + $this->expectException(\UnexpectedValueException::class); + $this->expectExceptionMessage('Collection item data must be an array.'); + + $response->collection(User::class, key: 'data'); + } + + public function testEnvelopeCanBeMapped(): void + { + $response = new Response(['data' => ['id' => 1, 'name' => 'John']], new PsrResponse(status: 202)); + + $envelope = $response->envelope(UserEnvelope::class); + + $this->assertSame(202, $envelope->getStatusCode()); + $this->assertSame(1, $envelope->getUser()->getId()); + $this->assertSame('John', $envelope->getUser()->getName()); + $this->assertNull($envelope->getTimezone()); + } +}