From 7a7b1a7aa0c6501904a0a33a10f845b471d82366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Pimp=C3=A3o?= <41913452+andrepimpao@users.noreply.github.com> Date: Sun, 5 Oct 2025 11:45:36 +0100 Subject: [PATCH 1/7] chore: add php8.4 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8274e4a..97b4a8a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - php: ['8.1', '8.2', '8.3'] + php: ['8.1', '8.2', '8.3', '8.4'] steps: - name: Checkout code From ea11afde921be890cbfcf809d66922d525ca1787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 5 Oct 2025 11:47:09 +0100 Subject: [PATCH 2/7] chore: add symfony 7 support --- .ddev/config.yaml | 74 +++++++++++++++++++++++++++++------------------ composer.json | 28 +++++++++--------- 2 files changed, 60 insertions(+), 42 deletions(-) diff --git a/.ddev/config.yaml b/.ddev/config.yaml index 79bb624..eae61b9 100644 --- a/.ddev/config.yaml +++ b/.ddev/config.yaml @@ -13,32 +13,38 @@ omit_containers: [db] use_dns_when_possible: true composer_version: "2" web_environment: [] +corepack_enable: false disable_upload_dirs_warning: true # Key features of DDEV's config.yaml: # name: # Name of the project, automatically provides # http://projectname.ddev.site and https://projectname.ddev.site +# If the name is omitted, the project will take the name of the enclosing directory, +# which is useful if you want to have a copy of the project side by side with this one. -# type: # backdrop, craftcms, django4, drupal6/7/8/9/10, laravel, magento, magento2, php, python, shopware6, silverstripe, typo3, wordpress -# See https://ddev.readthedocs.io/en/latest/users/quickstart/ for more +# type: # backdrop, cakephp, craftcms, drupal, drupal6, drupal7, drupal8, drupal9, drupal10, drupal11, generic, laravel, magento, magento2, php, shopware6, silverstripe, symfony, typo3, wordpress +# See https://docs.ddev.com/en/stable/users/quickstart/ for more # information on the different project types # docroot: # Relative path to the directory containing index.php. -# php_version: "8.1" # PHP version to use, "5.6", "7.0", "7.1", "7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3" +# php_version: "8.3" # PHP version to use, "5.6" through "8.4" # You can explicitly specify the webimage but this # is not recommended, as the images are often closely tied to DDEV's' behavior, # so this can break upgrades. -# webimage: # nginx/php docker image. +# webimage: +# It’s unusual to change this option, and we don’t recommend it without Docker experience and a good reason. +# Typically, this means additions to the existing web image using a .ddev/web-build/Dockerfile.* # database: # type: # mysql, mariadb, postgres -# version: # database version, like "10.4" or "8.0" -# MariaDB versions can be 5.5-10.8 and 10.11, MySQL versions can be 5.5-8.0 -# PostgreSQL versions can be 9-16. +# version: # database version, like "10.11" or "8.0" +# MariaDB versions can be 5.5-10.8, 10.11, 11.4, 11.8 +# MySQL versions can be 5.5-8.0, 8.4 +# PostgreSQL versions can be 9-17 # router_http_port: # Port to be used for http (defaults to global configuration, usually 80) # router_https_port: # Port for https (defaults to global configuration, usually 443) @@ -48,14 +54,25 @@ disable_upload_dirs_warning: true # "ddev xdebug" to enable Xdebug and "ddev xdebug off" to disable it work better, # as leaving Xdebug enabled all the time is a big performance hit. -# xhprof_enabled: false # Set to true to enable Xhprof and "ddev start" or "ddev restart" -# Note that for most people the commands -# "ddev xhprof" to enable Xhprof and "ddev xhprof off" to disable it work better, -# as leaving Xhprof enabled all the time is a big performance hit. +# xhgui_http_port: "8143" +# xhgui_https_port: "8142" +# The XHGui ports can be changed from the default 8143 and 8142 +# Very rarely used + +# host_xhgui_port: "8142" +# Can be used to change the host binding port of the XHGui +# application. Rarely used; only when port conflict and +# bind_all_ports is used (normally with router disabled) -# webserver_type: nginx-fpm, apache-fpm, or nginx-gunicorn +# xhprof_mode: [prepend|xhgui|global] +# Set to "xhgui" to enable XHGui features +# "xhgui" will become default in a future major release + +# webserver_type: nginx-fpm, apache-fpm, generic # timezone: Europe/Berlin +# If timezone is unset, DDEV will attempt to derive it from the host system timezone +# using the $TZ environment variable or the /etc/localtime symlink. # This is the timezone used in the containers and by PHP; # it can be set to any valid timezone, # see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones @@ -75,16 +92,17 @@ disable_upload_dirs_warning: true # - preview # - snapshot # Alternatively, an explicit Composer version may be specified, for example "2.2.18". -# To reinstall Composer after the image was built, run "ddev debug refresh". +# To reinstall Composer after the image was built, run "ddev debug rebuild". -# nodejs_version: "18" +# nodejs_version: "22" # change from the default system Node.js version to any other version. -# Numeric version numbers can be complete (i.e. 18.15.0) or -# incomplete (18, 17.2, 16). 'lts' and 'latest' can be used as well along with -# other named releases. -# see https://www.npmjs.com/package/n#specifying-nodejs-versions -# Note that you can continue using 'ddev nvm' or nvm inside the web container -# to change the project's installed node version if you need to. +# See https://docs.ddev.com/en/stable/users/configuration/config/#nodejs_version for more information +# and https://www.npmjs.com/package/n#specifying-nodejs-versions for the full documentation, +# Note that using of 'ddev nvm' is discouraged because "nodejs_version" is much easier to use, +# can specify any version, and is more robust than using 'nvm'. + +# corepack_enable: false +# Change to 'true' to 'corepack enable' and gain access to latest versions of yarn/pnpm # additional_hostnames: # - somename @@ -114,7 +132,7 @@ disable_upload_dirs_warning: true # ddev_version_constraint: "" # Example: -# ddev_version_constraint: ">= 1.22.4" +# ddev_version_constraint: ">= 1.24.8" # This will enforce that the running ddev version is within this constraint. # See https://github.com/Masterminds/semver#checking-version-constraints for # supported constraint formats @@ -143,8 +161,8 @@ disable_upload_dirs_warning: true # - "mutagen": enables Mutagen for this project. # - "nfs": enables NFS for this project. # -# See https://ddev.readthedocs.io/en/latest/users/install/performance/#nfs -# See https://ddev.readthedocs.io/en/latest/users/install/performance/#mutagen +# See https://docs.ddev.com/en/stable/users/install/performance/#nfs +# See https://docs.ddev.com/en/stable/users/install/performance/#mutagen # fail_on_hook_fail: False # Decide whether 'ddev start' should be interrupted by a failing hook @@ -193,17 +211,17 @@ disable_upload_dirs_warning: true # ngrok_args: --basic-auth username:pass1234 # Provide extra flags to the "ngrok http" command, see -# https://ngrok.com/docs/ngrok-agent/config or run "ngrok http -h" +# https://ngrok.com/docs/agent/config/v3/#agent-configuration or run "ngrok http -h" # disable_settings_management: false # If true, DDEV will not create CMS-specific settings files like -# Drupal's settings.php/settings.ddev.php or TYPO3's AdditionalConfiguration.php +# Drupal's settings.php/settings.ddev.php or TYPO3's additional.php # In this case the user must provide all such settings. # You can inject environment variables into the web container with: # web_environment: -# - SOMEENV=somevalue -# - SOMEOTHERENV=someothervalue +# - SOMEENV=somevalue +# - SOMEOTHERENV=someothervalue # no_project_mount: false # (Experimental) If true, DDEV will not mount the project into the web container; @@ -271,7 +289,7 @@ disable_upload_dirs_warning: true # Many DDEV commands can be extended to run tasks before or after the # DDEV command is executed, for example "post-start", "post-import-db", # "pre-composer", "post-composer" -# See https://ddev.readthedocs.io/en/stable/users/extend/custom-commands/ for more +# See https://docs.ddev.com/en/stable/users/extend/custom-commands/ for more # information on the commands that can be extended and the tasks you can define # for them. Example: #hooks: diff --git a/composer.json b/composer.json index 55450c8..fa1fde6 100644 --- a/composer.json +++ b/composer.json @@ -6,35 +6,35 @@ "license": "MIT", "authors": [ { - "name": "André Pimpão", - "email": "a.pimpao@programmator.dev", - "homepage": "https://programmator.dev/" + "name": "Programmator", + "email": "hotline@programmator.dev", + "homepage": "https://programmator.dev" } ], "require": { "php": ">=8.1", "php-http/cache-plugin": "^2.0", "php-http/client-common": "^2.7", - "php-http/discovery": "^1.19", + "php-http/discovery": "^1.20", "php-http/logger-plugin": "^1.3", "php-http/message": "^1.16", - "psr/cache": "^2.0 || ^3.0", + "psr/cache": "^2.0|^3.0", "psr/http-client": "^1.0", "psr/http-client-implementation": "*", - "psr/http-factory": "^1.0", + "psr/http-factory": "^1.1", "psr/http-factory-implementation": "*", - "psr/log": "^2.0 || ^3.0", - "symfony/event-dispatcher": "^6.4", - "symfony/options-resolver": "^6.4" + "psr/log": "^2.0|^3.0", + "symfony/event-dispatcher": "^6.4|^7.3", + "symfony/options-resolver": "^6.4|^7.3" }, "require-dev": { - "monolog/monolog": "^3.5", + "monolog/monolog": "^3.9", "nyholm/psr7": "^1.8", "php-http/mock-client": "^1.6", - "phpunit/phpunit": "^10.0", - "symfony/cache": "^6.4", - "symfony/http-client": "^6.4", - "symfony/var-dumper": "^6.4" + "phpunit/phpunit": "^10.5", + "symfony/cache": "^6.4|^7.3", + "symfony/http-client": "^6.4|^7.3", + "symfony/var-dumper": "^6.4|^7.3" }, "provide": { "psr/http-client-implementation": "1.0", From f8b91c9304af548e6d588318b821b2c7b91ac549 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 5 Oct 2025 13:25:46 +0100 Subject: [PATCH 3/7] chore: optional setBaseUrl --- README.md | 47 ++++++-------- src/Api.php | 26 +++----- src/Builder/ClientBuilder.php | 4 +- src/Builder/Listener/CacheLoggerListener.php | 6 +- src/Exception/ConfigException.php | 5 -- src/Helper/StringHelper.php | 11 ++++ src/Helper/StringHelperTrait.php | 11 ---- tests/Integration/ApiTest.php | 66 +++++++------------- tests/Unit/Builder/ClientBuilderTest.php | 2 +- tests/Unit/Helper/StringHelperTest.php | 17 +++++ tests/Unit/Helper/StringHelperTraitTest.php | 30 --------- 11 files changed, 82 insertions(+), 143 deletions(-) delete mode 100644 src/Exception/ConfigException.php create mode 100644 src/Helper/StringHelper.php delete mode 100644 src/Helper/StringHelperTrait.php create mode 100644 tests/Unit/Helper/StringHelperTest.php delete mode 100644 tests/Unit/Helper/StringHelperTraitTest.php diff --git a/README.md b/README.md index 18df2fa..5bb596c 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ class YourApi extends Api { parent::__construct(); - // minimum required config + // recommended config $this->setBaseUrl('https://api.example.com/v1'); } @@ -77,11 +77,11 @@ 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 +$this->setBaseUrl(?string $baseUrl): self ``` ```php -$this->getBaseUrl(): string +$this->getBaseUrl(): ?string ``` ### Requests @@ -99,16 +99,12 @@ use Psr\Http\Message\StreamInterface; $this->request( string $method, string $path, - array $query [], + array $query = [], array $headers = [], StreamInterface|string $body = null ): mixed ``` -> [!NOTE] -> A `ConfigException` will be thrown if a base URL is not set (this is, if it is empty). -> Check the [`setBaseUrl`](#base-url) method for more information. - > [!NOTE] > A `ClientException` will be thrown if there is an error while processing the request. @@ -123,7 +119,7 @@ class YourApi extends Api { parent::__construct(); - // minimum required config + // recommended config $this->setBaseUrl('https://api.example.com/v1'); } @@ -165,6 +161,7 @@ class YourApi extends Api { parent::__construct(); + // recommended config $this->setBaseUrl('https://api.example.com/v1'); } @@ -316,18 +313,11 @@ use Http\Message\Authentication; $this->getAuthentication(): ?Authentication; ``` -Available authentication methods: -- [`BasicAuth`](https://docs.php-http.org/en/latest/message/authentication.html#id1) Username and password -- [`Bearer`](https://docs.php-http.org/en/latest/message/authentication.html#bearer) Token -- [`Wsse`](https://docs.php-http.org/en/latest/message/authentication.html#id2) Username and password -- [`QueryParam`](https://docs.php-http.org/en/latest/message/authentication.html#query-params) Array of query parameter values -- [`Header`](https://docs.php-http.org/en/latest/message/authentication.html#header) Header name and value -- [`Chain`](https://docs.php-http.org/en/latest/message/authentication.html#chain) Array of authentication instances -- `RequestConditional` A request matcher and authentication instances +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 that is authenticated with a query parameter: +For example, if you have an API authenticated with a query parameter: ```php use ProgrammatorDev\Api\Api; @@ -367,7 +357,7 @@ class YourApi extends Api #### `addPreRequestListener` -The `addPreRequestListener` method is used to add a function that is called before a request has been made. +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 @@ -413,7 +403,7 @@ $this->addPreRequestListener(function(PreRequestEvent $event) { #### `addPostRequestListener` -The `addPostRequestListener` method is used to add a function that is called after a request has been made. +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. @@ -468,7 +458,7 @@ $this->addPostRequestListener(function(PostRequestEvent $event) { #### `addResponseContentsListener` -The `addResponseContentsListener` method is used to manipulate the response that was received from the API. +The `addResponseContentsListener` method is used to manipulate the response received from the API. This event listener will be applied to every API request. ```php @@ -648,7 +638,7 @@ class YourApi extends Api 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 are executed in descending order from highest to lowest. +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). @@ -673,7 +663,7 @@ The following list has all the implemented plugins with the respective priority | [`LoggerPlugin`](https://docs.php-http.org/en/latest/plugins/logger.html) | 8 | only if logger is enabled | 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): +(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; @@ -686,7 +676,7 @@ class YourApi extends Api // ... // if a request fails, it will retry at least 3 times - // priority is 20 to execute before the cache plugin + // 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]), @@ -709,12 +699,11 @@ use Psr\Cache\CacheItemPoolInterface; new CacheBuilder( // a PSR-6 cache adapter CacheItemPoolInterface $pool, - // default lifetime (in seconds) of cache items + // 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, - // in order to determine cacheability + // An array of cache directives to be compared with the headers of the HTTP response to determine cacheability $responseCacheDirectives = ['max-age'] ); ``` @@ -854,7 +843,7 @@ class YourApi extends Api private function configureOptions(array $options): array { - // set defaults values, if none were provided + // set defaults values if none were provided $this->optionsResolver->setDefault('timezone', 'UTC'); $this->optionsResolver->setDefault('language', 'en'); @@ -872,7 +861,7 @@ class YourApi extends Api private function configureApi(): void { - // set required base url + // set the base url $this->setBaseUrl('https://api.example.com/v1'); // set options as query defaults (will be included in all requests) diff --git a/src/Api.php b/src/Api.php index 9508649..f477949 100644 --- a/src/Api.php +++ b/src/Api.php @@ -15,8 +15,7 @@ use ProgrammatorDev\Api\Event\PostRequestEvent; use ProgrammatorDev\Api\Event\PreRequestEvent; use ProgrammatorDev\Api\Event\ResponseContentsEvent; -use ProgrammatorDev\Api\Exception\ConfigException; -use ProgrammatorDev\Api\Helper\StringHelperTrait; +use ProgrammatorDev\Api\Helper\StringHelper; use Psr\Http\Client\ClientExceptionInterface as ClientException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamInterface; @@ -25,8 +24,6 @@ class Api { - use StringHelperTrait; - private ?string $baseUrl = null; private array $queryDefaults = []; @@ -53,7 +50,6 @@ public function __construct() } /** - * @throws ConfigException If a base URL has not been set. * @throws ClientException */ public function request( @@ -64,18 +60,14 @@ public function request( string|StreamInterface $body = null ): mixed { - if (!$this->baseUrl) { - throw new ConfigException('A base URL must be set.'); - } - $this->configurePlugins(); if (!empty($this->queryDefaults)) { - $query = \array_merge($this->queryDefaults, $query); + $query = array_merge($this->queryDefaults, $query); } if (!empty($this->headerDefaults)) { - $headers = \array_merge($this->headerDefaults, $headers); + $headers = array_merge($this->headerDefaults, $headers); } $uri = $this->buildUri($path, $query); @@ -161,7 +153,7 @@ public function getBaseUrl(): ?string return $this->baseUrl; } - public function setBaseUrl(string $baseUrl): self + public function setBaseUrl(?string $baseUrl): self { $this->baseUrl = $baseUrl; @@ -278,8 +270,8 @@ public function addResponseContentsListener(callable $listener, int $priority = public function buildPath(string $path, array $parameters): string { foreach ($parameters as $parameter => $value) { - $path = \str_replace( - \sprintf('{%s}', $parameter), + $path = str_replace( + sprintf('{%s}', $parameter), $value, $path ); @@ -290,10 +282,10 @@ public function buildPath(string $path, array $parameters): string private function buildUri(string $path, array $query = []): string { - $uri = $this->reduceDuplicateSlashes($this->baseUrl . $path); + $uri = StringHelper::reduceDuplicateSlashes($this->baseUrl . $path); if (!empty($query)) { - $uri = \sprintf('%s?%s', $uri, \http_build_query($query)); + $uri = sprintf('%s?%s', $uri, http_build_query($query)); } return $uri; @@ -314,7 +306,7 @@ private function createRequest( if ($body !== null && $body !== '') { $request = $request->withBody( - \is_string($body) ? $this->clientBuilder->getStreamFactory()->createStream($body) : $body + is_string($body) ? $this->clientBuilder->getStreamFactory()->createStream($body) : $body ); } diff --git a/src/Builder/ClientBuilder.php b/src/Builder/ClientBuilder.php index 7c20c93..2a636f3 100644 --- a/src/Builder/ClientBuilder.php +++ b/src/Builder/ClientBuilder.php @@ -75,13 +75,13 @@ public function addPlugin(Plugin $plugin, int $priority): self { if (isset($this->plugins[$priority])) { throw new PluginException( - \sprintf('A plugin with priority %d already exists.', $priority) + sprintf('A plugin with priority %d already exists.', $priority) ); } $this->plugins[$priority] = $plugin; // sort plugins by priority (key) in descending order - \krsort($this->plugins); + krsort($this->plugins); return $this; } diff --git a/src/Builder/Listener/CacheLoggerListener.php b/src/Builder/Listener/CacheLoggerListener.php index b865dbe..74a3180 100644 --- a/src/Builder/Listener/CacheLoggerListener.php +++ b/src/Builder/Listener/CacheLoggerListener.php @@ -26,7 +26,7 @@ public function onCacheResponse( if ($fromCache) { /** @var $cacheItem CacheItemInterface */ $logger->info( - \sprintf("Cache hit:\n%s", $formatter->formatRequest($request)), + sprintf("Cache hit:\n%s", $formatter->formatRequest($request)), [ 'expires' => $cacheItem->get()['expiresAt'], 'key' => $cacheItem->getKey() @@ -36,12 +36,12 @@ public function onCacheResponse( // if response is a cache miss (and was cached) else if ($cacheItem instanceof CacheItemInterface) { // handle future deprecation - $formattedResponse = \method_exists($formatter, 'formatResponseForRequest') + $formattedResponse = method_exists($formatter, 'formatResponseForRequest') ? $formatter->formatResponseForRequest($response, $request) : $formatter->formatResponse($response); $logger->info( - \sprintf("Cached response:\n%s", $formattedResponse), + sprintf("Cached response:\n%s", $formattedResponse), [ 'expires' => $cacheItem->get()['expiresAt'], 'key' => $cacheItem->getKey() diff --git a/src/Exception/ConfigException.php b/src/Exception/ConfigException.php deleted file mode 100644 index b74a501..0000000 --- a/src/Exception/ConfigException.php +++ /dev/null @@ -1,5 +0,0 @@ -api->setClientBuilder(new ClientBuilder($this->mockClient)); } - public function testSetters() - { - $pool = $this->createMock(CacheItemPoolInterface::class); - $logger = $this->createMock(LoggerInterface::class); - $authentication = $this->createConfiguredMock(Authentication::class, [ - 'authenticate' => $this->createMock(RequestInterface::class) - ]); - - $this->api->setBaseUrl(self::BASE_URL); - $this->api->setClientBuilder(new ClientBuilder()); - $this->api->setCacheBuilder(new CacheBuilder($pool)); - $this->api->setLoggerBuilder(new LoggerBuilder($logger)); - $this->api->setAuthentication($authentication); - - $this->assertSame(self::BASE_URL, $this->api->getBaseUrl()); - $this->assertInstanceOf(ClientBuilder::class, $this->api->getClientBuilder()); - $this->assertInstanceOf(CacheBuilder::class, $this->api->getCacheBuilder()); - $this->assertInstanceOf(LoggerBuilder::class, $this->api->getLoggerBuilder()); - $this->assertInstanceOf(Authentication::class, $this->api->getAuthentication()); - } - public function testRequest() { $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); - $this->api->setBaseUrl(self::BASE_URL); - $response = $this->api->request( method: 'GET', path: '/path' @@ -72,15 +48,13 @@ public function testRequest() $this->assertSame(MockResponse::SUCCESS, $response); } - public function testMissingBaseUrl() + public function testBaseUrl() { - $this->expectException(ConfigException::class); - $this->expectExceptionMessage('A base URL must be set.'); + $this->assertNull($this->api->getBaseUrl()); - $this->api->request( - method: 'GET', - path: '/path' - ); + $this->api->setBaseUrl(self::BASE_URL); + + $this->assertSame(self::BASE_URL, $this->api->getBaseUrl()); } public function testQueryDefaults() @@ -103,12 +77,13 @@ public function testHeaderDefaults() public function testCache() { - $pool = $this->createMock(CacheItemPoolInterface::class); + $this->assertNull($this->api->getCacheBuilder()); - $this->api->setBaseUrl(self::BASE_URL); - $this->api->setCacheBuilder(new CacheBuilder($pool)); + $cachePool = $this->createMock(CacheItemPoolInterface::class); - $pool->expects($this->once())->method('save'); + $this->api->setCacheBuilder(new CacheBuilder($cachePool)); + + $cachePool->expects($this->once())->method('save'); $this->api->request( method: 'GET', @@ -118,12 +93,13 @@ public function testCache() public function testLogger() { + $this->assertNull($this->api->getLoggerBuilder()); + $logger = $this->createMock(LoggerInterface::class); - $this->api->setBaseUrl(self::BASE_URL); $this->api->setLoggerBuilder(new LoggerBuilder($logger)); - // request + response log + // count equals 2 because of the request and response log $logger->expects($this->exactly(2))->method('info'); $this->api->request( @@ -134,14 +110,16 @@ public function testLogger() public function testCacheWithLogger() { - $pool = $this->createMock(CacheItemPoolInterface::class); + $this->assertNull($this->api->getCacheBuilder()); + $this->assertNull($this->api->getLoggerBuilder()); + + $cachePool = $this->createMock(CacheItemPoolInterface::class); $logger = $this->createMock(LoggerInterface::class); - $this->api->setBaseUrl(self::BASE_URL); - $this->api->setCacheBuilder(new CacheBuilder($pool)); + $this->api->setCacheBuilder(new CacheBuilder($cachePool)); $this->api->setLoggerBuilder(new LoggerBuilder($logger)); - // request + response + cache log + // count equals 3 because of the request, response and cache log $logger->expects($this->exactly(3))->method('info'); // error suppression to hide expected warning of null cache item in CacheLoggerListener @@ -155,11 +133,12 @@ public function testCacheWithLogger() public function testAuthentication() { + $this->assertNull($this->api->getAuthentication()); + $authentication = $this->createConfiguredMock(Authentication::class, [ 'authenticate' => $this->createMock(RequestInterface::class) ]); - $this->api->setBaseUrl(self::BASE_URL); $this->api->setAuthentication($authentication); $authentication->expects($this->once())->method('authenticate'); @@ -172,7 +151,6 @@ public function testAuthentication() public function testPreRequestListener() { - $this->api->setBaseUrl(self::BASE_URL); $this->api->addPreRequestListener(fn() => throw new \Exception('TestMessage')); $this->expectException(\Exception::class); @@ -186,7 +164,6 @@ public function testPreRequestListener() public function testPostRequestListener() { - $this->api->setBaseUrl(self::BASE_URL); $this->api->addPostRequestListener(fn() => throw new \Exception('TestMessage')); $this->expectException(\Exception::class); @@ -202,7 +179,6 @@ public function testResponseContentsListener() { $this->mockClient->addResponse(new Response(body: MockResponse::SUCCESS)); - $this->api->setBaseUrl(self::BASE_URL); $this->api->addResponseContentsListener(function(ResponseContentsEvent $event) { $contents = json_decode($event->getContents(), true); $event->setContents($contents); diff --git a/tests/Unit/Builder/ClientBuilderTest.php b/tests/Unit/Builder/ClientBuilderTest.php index 7738487..9a3ca0c 100644 --- a/tests/Unit/Builder/ClientBuilderTest.php +++ b/tests/Unit/Builder/ClientBuilderTest.php @@ -61,7 +61,7 @@ public function testAddPlugin() $this->assertCount(3, $clientBuilder->getPlugins()); // plugins array keys are used as priority [priority => plugin] - // so check if order of keys (priority) is sorted + // so check if the order of keys (priority) is sorted $this->assertSame( [ 0 => 3, diff --git a/tests/Unit/Helper/StringHelperTest.php b/tests/Unit/Helper/StringHelperTest.php new file mode 100644 index 0000000..706c8de --- /dev/null +++ b/tests/Unit/Helper/StringHelperTest.php @@ -0,0 +1,17 @@ +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/StringHelperTraitTest.php b/tests/Unit/Helper/StringHelperTraitTest.php deleted file mode 100644 index d52eec1..0000000 --- a/tests/Unit/Helper/StringHelperTraitTest.php +++ /dev/null @@ -1,30 +0,0 @@ -class = new class { - use StringHelperTrait { - reduceDuplicateSlashes as public; - } - }; - } - - public function testReduceDuplicateSlashes() - { - $this->assertSame( - 'https://example.com/path/test', - $this->class->reduceDuplicateSlashes('https://example.com////path//test') - ); - } -} \ No newline at end of file From d2834a15eb49dfec5610485b5e42b49fc339dae1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 5 Oct 2025 15:37:06 +0100 Subject: [PATCH 4/7] chore: remove OptionsResolver --- README.md | 79 --------------------------------------------------- composer.json | 3 +- src/Api.php | 4 --- 3 files changed, 1 insertion(+), 85 deletions(-) diff --git a/README.md b/README.md index 5bb596c..006705c 100644 --- a/README.md +++ b/README.md @@ -817,85 +817,6 @@ class YourApi extends Api } ``` -### Configure Options - -It is very common for APIs to offer different options (like language, timezone, etc.). -To simplify the process of configuring options, the [`OptionsResolver`](https://symfony.com/doc/current/components/options_resolver.html) is available. -It allows you to create a set of default options and their constraints such as required options, default values, allowed types, etc. -It then resolves the given options `array` against these default options to ensure it meets all the constraints. - -For example, if an API has a language and timezone options: - -```php -use ProgrammatorDev\Api\Api; - -class YourApi extends Api -{ - private array $options = []; - - public function __construct(array $options = []) - { - parent::__construct(); - - $this->options = $this->configureOptions($options); - $this->configureApi(); - } - - private function configureOptions(array $options): array - { - // set defaults values if none were provided - $this->optionsResolver->setDefault('timezone', 'UTC'); - $this->optionsResolver->setDefault('language', 'en'); - - // set allowed types - $this->optionsResolver->setAllowedTypes('timezone', 'string'); - $this->optionsResolver->setAllowedTypes('language', 'string'); - - // set allowed values - $this->optionsResolver->setAllowedValues('timezone', \DateTimeZone::listIdentifiers()); - $this->optionsResolver->setAllowedValues('language', ['en', 'pt']); - - // return resolved options - return $this->optionsResolver->resolve($options); - } - - private function configureApi(): void - { - // set the base url - $this->setBaseUrl('https://api.example.com/v1'); - - // set options as query defaults (will be included in all requests) - $this->addQueryDefault('language', $this->options['language']); - $this->addQueryDefault('timezone', $this->options['timezone']); - } - - public function getPosts(int $page = 1): string - { - // GET https://api.example.com/v1/posts?language=en&timezone=UTC&page=1 - return $this->request( - method: 'GET', - path: '/posts', - query: [ - 'page' => $page - ] - ); - } -} -``` - -When using the API, it should look like this: - -```php -$api = new YourApi([ - 'language' => 'pt' -]); - -// GET https://api.example.com/v1/posts?language=pt&timezone=UTC&page=1 -$posts = $api->getPosts(); -``` - -For all available methods, check the official page [documentation](https://symfony.com/doc/current/components/options_resolver.html). - ## Libraries using PHP API SDK - [programmatordev/openweathermap-php-api](https://github.com/programmatordev/openweathermap-php-api) diff --git a/composer.json b/composer.json index fa1fde6..126c4d9 100644 --- a/composer.json +++ b/composer.json @@ -24,8 +24,7 @@ "psr/http-factory": "^1.1", "psr/http-factory-implementation": "*", "psr/log": "^2.0|^3.0", - "symfony/event-dispatcher": "^6.4|^7.3", - "symfony/options-resolver": "^6.4|^7.3" + "symfony/event-dispatcher": "^6.4|^7.3" }, "require-dev": { "monolog/monolog": "^3.9", diff --git a/src/Api.php b/src/Api.php index f477949..7a680a2 100644 --- a/src/Api.php +++ b/src/Api.php @@ -20,7 +20,6 @@ use Psr\Http\Message\RequestInterface; use Psr\Http\Message\StreamInterface; use Symfony\Component\EventDispatcher\EventDispatcher; -use Symfony\Component\OptionsResolver\OptionsResolver; class Api { @@ -40,13 +39,10 @@ class Api private EventDispatcher $eventDispatcher; - protected OptionsResolver $optionsResolver; - public function __construct() { $this->clientBuilder ??= new ClientBuilder(); $this->eventDispatcher = new EventDispatcher(); - $this->optionsResolver = new OptionsResolver(); } /** From 9062751d5cf62d25493a4ccfe4f73fd6f190ae9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Mon, 6 Oct 2025 23:10:00 +0100 Subject: [PATCH 5/7] api: improve url build to better handle query parameters --- composer.json | 1 + src/Api.php | 19 ++++++++++--------- src/Helper/StringHelper.php | 5 +++++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index 126c4d9..31cef0b 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ ], "require": { "php": ">=8.1", + "nyholm/append-query-string": "^1.0", "php-http/cache-plugin": "^2.0", "php-http/client-common": "^2.7", "php-http/discovery": "^1.20", diff --git a/src/Api.php b/src/Api.php index 7a680a2..54bbdba 100644 --- a/src/Api.php +++ b/src/Api.php @@ -66,8 +66,8 @@ public function request( $headers = array_merge($this->headerDefaults, $headers); } - $uri = $this->buildUri($path, $query); - $request = $this->createRequest($method, $uri, $headers, $body); + $url = $this->buildUrl($path, $query); + $request = $this->createRequest($method, $url, $headers, $body); // pre request listener $request = $this->eventDispatcher->dispatch(new PreRequestEvent($request))->getRequest(); @@ -276,25 +276,26 @@ public function buildPath(string $path, array $parameters): string return $path; } - private function buildUri(string $path, array $query = []): string + private function buildUrl(string $path, array $query = []): string { - $uri = StringHelper::reduceDuplicateSlashes($this->baseUrl . $path); + $appendQuery = http_build_query($query); - if (!empty($query)) { - $uri = sprintf('%s?%s', $uri, http_build_query($query)); + if (StringHelper::isUrl($path)) { + return append_query_string($path, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); } - return $uri; + $url = StringHelper::reduceDuplicateSlashes($this->baseUrl . $path); + return append_query_string($url, $appendQuery, APPEND_QUERY_STRING_REPLACE_DUPLICATE); } private function createRequest( string $method, - string $uri, + string $url, array $headers = [], string|StreamInterface $body = null ): RequestInterface { - $request = $this->clientBuilder->getRequestFactory()->createRequest($method, $uri); + $request = $this->clientBuilder->getRequestFactory()->createRequest($method, $url); foreach ($headers as $key => $value) { $request = $request->withHeader($key, $value); diff --git a/src/Helper/StringHelper.php b/src/Helper/StringHelper.php index 9b7686b..f15612d 100644 --- a/src/Helper/StringHelper.php +++ b/src/Helper/StringHelper.php @@ -8,4 +8,9 @@ public static function reduceDuplicateSlashes(string $string): string { return preg_replace('#(^|[^:])//+#', '\\1/', $string); } + + public static function isUrl(string $string): bool + { + return filter_var($string, FILTER_VALIDATE_URL) !== false; + } } \ No newline at end of file From c37f3fbd83a63a5034b9da9cb27af2c5ce9ff1ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 12 Oct 2025 15:45:07 +0100 Subject: [PATCH 6/7] test: add build url tests --- README.md | 1 - tests/Integration/ApiTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 006705c..bde7467 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,6 @@ class YourApi extends Api - [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) -- [Configure options](#configure-options) ### Base URL diff --git a/tests/Integration/ApiTest.php b/tests/Integration/ApiTest.php index cd0f152..5859e85 100644 --- a/tests/Integration/ApiTest.php +++ b/tests/Integration/ApiTest.php @@ -5,10 +5,12 @@ 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; @@ -192,6 +194,30 @@ public function testResponseContentsListener() $this->assertIsArray($response); } + #[DataProvider('provideBuildUrlData')] + public function testBuildUrl(?string $baseUrl, string $path, array $query, string $expectedUrl) + { + $this->api->addPreRequestListener(function(PreRequestEvent $event) use ($expectedUrl) { + $url = (string) $event->getRequest()->getUri(); + + $this->assertSame($expectedUrl, $url); + }); + + $this->api->setBaseUrl($baseUrl); + $this->api->request(method: 'GET', path: $path, query: $query); + } + + 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']; + } + public function testBuildPath() { $path = $this->api->buildPath('/path/{parameter1}/multiple/{parameter2}', [ From 3c83bffc3e8ffee92f05e21089f6c91aa03532ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andre=CC=81=20Pimpa=CC=83o?= Date: Sun, 12 Oct 2025 15:49:41 +0100 Subject: [PATCH 7/7] docs: add note about path full url --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index bde7467..ac6a4ce 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,9 @@ By default, this method will return a `string` as it will be the response of the 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.