├── CHANGELOG.md ├── Cache ├── CHANGELOG.md ├── CacheInterface.php ├── CacheTrait.php ├── CallbackInterface.php ├── ItemInterface.php ├── LICENSE ├── NamespacedPoolInterface.php ├── README.md ├── TagAwareCacheInterface.php └── composer.json ├── Deprecation ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── function.php ├── EventDispatcher ├── CHANGELOG.md ├── Event.php ├── EventDispatcherInterface.php ├── LICENSE ├── README.md └── composer.json ├── HttpClient ├── CHANGELOG.md ├── ChunkInterface.php ├── Exception │ ├── ClientExceptionInterface.php │ ├── DecodingExceptionInterface.php │ ├── ExceptionInterface.php │ ├── HttpExceptionInterface.php │ ├── RedirectionExceptionInterface.php │ ├── ServerExceptionInterface.php │ ├── TimeoutExceptionInterface.php │ └── TransportExceptionInterface.php ├── HttpClientInterface.php ├── LICENSE ├── README.md ├── ResponseInterface.php ├── ResponseStreamInterface.php ├── Test │ ├── Fixtures │ │ └── web │ │ │ └── index.php │ ├── HttpClientTestCase.php │ └── TestHttpServer.php └── composer.json ├── LICENSE ├── README.md ├── Service ├── Attribute │ ├── Required.php │ └── SubscribedService.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── ResetInterface.php ├── ServiceCollectionInterface.php ├── ServiceLocatorTrait.php ├── ServiceMethodsSubscriberTrait.php ├── ServiceProviderInterface.php ├── ServiceSubscriberInterface.php ├── ServiceSubscriberTrait.php ├── Test │ ├── ServiceLocatorTest.php │ └── ServiceLocatorTestCase.php └── composer.json ├── Tests ├── Cache │ └── CacheTraitTest.php └── Service │ ├── LegacyTestService.php │ ├── ServiceMethodsSubscriberTraitTest.php │ └── ServiceSubscriberTraitTest.php ├── Translation ├── CHANGELOG.md ├── LICENSE ├── LocaleAwareInterface.php ├── README.md ├── Test │ └── TranslatorTest.php ├── TranslatableInterface.php ├── TranslatorInterface.php ├── TranslatorTrait.php └── composer.json ├── composer.json └── phpunit.xml.dist /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 3.6 5 | --- 6 | 7 | * Make `HttpClientTestCase` and `TranslatorTest` compatible with PHPUnit 10+ 8 | * Add `NamespacedPoolInterface` to support namespace-based invalidation 9 | 10 | 3.5 11 | --- 12 | 13 | * Add `ServiceCollectionInterface` 14 | * Deprecate `ServiceSubscriberTrait`, use `ServiceMethodsSubscriberTrait` instead 15 | 16 | 3.4 17 | --- 18 | 19 | * Allow custom working directory in `TestHttpServer` 20 | 21 | 3.3 22 | --- 23 | 24 | * Add option `crypto_method` to `HttpClientInterface` to define the minimum TLS version to accept 25 | 26 | 3.2 27 | --- 28 | 29 | * Allow `ServiceSubscriberInterface::getSubscribedServices()` to return `SubscribedService[]` 30 | 31 | 3.0 32 | --- 33 | 34 | * Bump to PHP 8 minimum 35 | * Add native return types 36 | * Remove deprecated features 37 | 38 | 2.5 39 | --- 40 | 41 | * Add `SubscribedService` attribute, deprecate current `ServiceSubscriberTrait` usage 42 | 43 | 2.4 44 | --- 45 | 46 | * Add `HttpClientInterface::withOptions()` 47 | * Add `TranslatorInterface::getLocale()` 48 | 49 | 2.3.0 50 | ----- 51 | 52 | * added `Translation\TranslatableInterface` to enable value-objects to be translated 53 | * made `Translation\TranslatorTrait::getLocale()` fallback to intl's `Locale::getDefault()` when available 54 | 55 | 2.2.0 56 | ----- 57 | 58 | * added `Service\Attribute\Required` attribute for PHP 8 59 | 60 | 2.1.3 61 | ----- 62 | 63 | * fixed compat with PHP 8 64 | 65 | 2.1.0 66 | ----- 67 | 68 | * added "symfony/deprecation-contracts" 69 | 70 | 2.0.1 71 | ----- 72 | 73 | * added `/json` endpoints to the test mock HTTP server 74 | 75 | 2.0.0 76 | ----- 77 | 78 | * bumped minimum PHP version to 7.2 and added explicit type hints 79 | * made "psr/event-dispatcher" a required dependency of "symfony/event-dispatcher-contracts" 80 | * made "symfony/http-client-contracts" not experimental anymore 81 | 82 | 1.1.9 83 | ----- 84 | 85 | * fixed compat with PHP 8 86 | 87 | 1.1.0 88 | ----- 89 | 90 | * added `HttpClient` namespace with contracts for implementing flexible HTTP clients 91 | * added `EventDispatcherInterface` and `Event` in namespace `EventDispatcher` 92 | * added `ServiceProviderInterface` in namespace `Service` 93 | 94 | 1.0.0 95 | ----- 96 | 97 | * added `Service\ResetInterface` to provide a way to reset an object to its initial state 98 | * added `Translation\TranslatorInterface` and `Translation\TranslatorTrait` 99 | * added `Cache` contract to extend PSR-6 with tag invalidation, callback-based computation and stampede protection 100 | * added `Service\ServiceSubscriberInterface` to declare the dependencies of a class that consumes a service locator 101 | * added `Service\ServiceSubscriberTrait` to implement `Service\ServiceSubscriberInterface` using methods' return types 102 | * added `Service\ServiceLocatorTrait` to help implement PSR-11 service locators 103 | -------------------------------------------------------------------------------- /Cache/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | The changelog is maintained for all Symfony contracts at the following URL: 5 | https://github.com/symfony/contracts/blob/main/CHANGELOG.md 6 | -------------------------------------------------------------------------------- /Cache/CacheInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Cache; 13 | 14 | use Psr\Cache\CacheItemInterface; 15 | use Psr\Cache\InvalidArgumentException; 16 | 17 | /** 18 | * Covers most simple to advanced caching needs. 19 | * 20 | * @author Nicolas Grekas 21 | */ 22 | interface CacheInterface 23 | { 24 | /** 25 | * Fetches a value from the pool or computes it if not found. 26 | * 27 | * On cache misses, a callback is called that should return the missing value. 28 | * This callback is given a PSR-6 CacheItemInterface instance corresponding to the 29 | * requested key, that could be used e.g. for expiration control. It could also 30 | * be an ItemInterface instance when its additional features are needed. 31 | * 32 | * @template T 33 | * 34 | * @param string $key The key of the item to retrieve from the cache 35 | * @param (callable(CacheItemInterface,bool):T)|(callable(ItemInterface,bool):T)|CallbackInterface $callback 36 | * @param float|null $beta A float that, as it grows, controls the likeliness of triggering 37 | * early expiration. 0 disables it, INF forces immediate expiration. 38 | * The default (or providing null) is implementation dependent but should 39 | * typically be 1.0, which should provide optimal stampede protection. 40 | * See https://en.wikipedia.org/wiki/Cache_stampede#Probabilistic_early_expiration 41 | * @param array &$metadata The metadata of the cached item {@see ItemInterface::getMetadata()} 42 | * 43 | * @return T 44 | * 45 | * @throws InvalidArgumentException When $key is not valid or when $beta is negative 46 | */ 47 | public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed; 48 | 49 | /** 50 | * Removes an item from the pool. 51 | * 52 | * @param string $key The key to delete 53 | * 54 | * @return bool True if the item was successfully removed, false if there was any error 55 | * 56 | * @throws InvalidArgumentException When $key is not valid 57 | */ 58 | public function delete(string $key): bool; 59 | } 60 | -------------------------------------------------------------------------------- /Cache/CacheTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Cache; 13 | 14 | use Psr\Cache\CacheItemPoolInterface; 15 | use Psr\Cache\InvalidArgumentException; 16 | use Psr\Log\LoggerInterface; 17 | 18 | // Help opcache.preload discover always-needed symbols 19 | class_exists(InvalidArgumentException::class); 20 | 21 | /** 22 | * An implementation of CacheInterface for PSR-6 CacheItemPoolInterface classes. 23 | * 24 | * @author Nicolas Grekas 25 | */ 26 | trait CacheTrait 27 | { 28 | public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed 29 | { 30 | return $this->doGet($this, $key, $callback, $beta, $metadata); 31 | } 32 | 33 | public function delete(string $key): bool 34 | { 35 | return $this->deleteItem($key); 36 | } 37 | 38 | private function doGet(CacheItemPoolInterface $pool, string $key, callable $callback, ?float $beta, ?array &$metadata = null, ?LoggerInterface $logger = null): mixed 39 | { 40 | if (0 > $beta ??= 1.0) { 41 | throw new class(\sprintf('Argument "$beta" provided to "%s::get()" must be a positive number, %f given.', static::class, $beta)) extends \InvalidArgumentException implements InvalidArgumentException {}; 42 | } 43 | 44 | $item = $pool->getItem($key); 45 | $recompute = !$item->isHit() || \INF === $beta; 46 | $metadata = $item instanceof ItemInterface ? $item->getMetadata() : []; 47 | 48 | if (!$recompute && $metadata) { 49 | $expiry = $metadata[ItemInterface::METADATA_EXPIRY] ?? false; 50 | $ctime = $metadata[ItemInterface::METADATA_CTIME] ?? false; 51 | 52 | if ($recompute = $ctime && $expiry && $expiry <= ($now = microtime(true)) - $ctime / 1000 * $beta * log(random_int(1, \PHP_INT_MAX) / \PHP_INT_MAX)) { 53 | // force applying defaultLifetime to expiry 54 | $item->expiresAt(null); 55 | $logger?->info('Item "{key}" elected for early recomputation {delta}s before its expiration', [ 56 | 'key' => $key, 57 | 'delta' => \sprintf('%.1f', $expiry - $now), 58 | ]); 59 | } 60 | } 61 | 62 | if ($recompute) { 63 | $save = true; 64 | $item->set($callback($item, $save)); 65 | if ($save) { 66 | $pool->save($item); 67 | } 68 | } 69 | 70 | return $item->get(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Cache/CallbackInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Cache; 13 | 14 | use Psr\Cache\CacheItemInterface; 15 | 16 | /** 17 | * Computes and returns the cached value of an item. 18 | * 19 | * @author Nicolas Grekas 20 | * 21 | * @template T 22 | */ 23 | interface CallbackInterface 24 | { 25 | /** 26 | * @param CacheItemInterface|ItemInterface $item The item to compute the value for 27 | * @param bool &$save Should be set to false when the value should not be saved in the pool 28 | * 29 | * @return T The computed value for the passed item 30 | */ 31 | public function __invoke(CacheItemInterface $item, bool &$save): mixed; 32 | } 33 | -------------------------------------------------------------------------------- /Cache/ItemInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Cache; 13 | 14 | use Psr\Cache\CacheException; 15 | use Psr\Cache\CacheItemInterface; 16 | use Psr\Cache\InvalidArgumentException; 17 | 18 | /** 19 | * Augments PSR-6's CacheItemInterface with support for tags and metadata. 20 | * 21 | * @author Nicolas Grekas 22 | */ 23 | interface ItemInterface extends CacheItemInterface 24 | { 25 | /** 26 | * References the Unix timestamp stating when the item will expire. 27 | */ 28 | public const METADATA_EXPIRY = 'expiry'; 29 | 30 | /** 31 | * References the time the item took to be created, in milliseconds. 32 | */ 33 | public const METADATA_CTIME = 'ctime'; 34 | 35 | /** 36 | * References the list of tags that were assigned to the item, as string[]. 37 | */ 38 | public const METADATA_TAGS = 'tags'; 39 | 40 | /** 41 | * Reserved characters that cannot be used in a key or tag. 42 | */ 43 | public const RESERVED_CHARACTERS = '{}()/\@:'; 44 | 45 | /** 46 | * Adds a tag to a cache item. 47 | * 48 | * Tags are strings that follow the same validation rules as keys. 49 | * 50 | * @param string|string[] $tags A tag or array of tags 51 | * 52 | * @return $this 53 | * 54 | * @throws InvalidArgumentException When $tag is not valid 55 | * @throws CacheException When the item comes from a pool that is not tag-aware 56 | */ 57 | public function tag(string|iterable $tags): static; 58 | 59 | /** 60 | * Returns a list of metadata info that were saved alongside with the cached value. 61 | * 62 | * See ItemInterface::METADATA_* consts for keys potentially found in the returned array. 63 | */ 64 | public function getMetadata(): array; 65 | } 66 | -------------------------------------------------------------------------------- /Cache/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Cache/NamespacedPoolInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Cache; 13 | 14 | use Psr\Cache\InvalidArgumentException; 15 | 16 | /** 17 | * Enables namespace-based invalidation by prefixing keys with backend-native namespace separators. 18 | * 19 | * Note that calling `withSubNamespace()` MUST NOT mutate the pool, but return a new instance instead. 20 | * 21 | * When tags are used, they MUST ignore sub-namespaces. 22 | * 23 | * @author Nicolas Grekas 24 | */ 25 | interface NamespacedPoolInterface 26 | { 27 | /** 28 | * @throws InvalidArgumentException If the namespace contains characters found in ItemInterface's RESERVED_CHARACTERS 29 | */ 30 | public function withSubNamespace(string $namespace): static; 31 | } 32 | -------------------------------------------------------------------------------- /Cache/README.md: -------------------------------------------------------------------------------- 1 | Symfony Cache Contracts 2 | ======================= 3 | 4 | A set of abstractions extracted out of the Symfony components. 5 | 6 | Can be used to build on semantics that the Symfony components proved useful and 7 | that already have battle tested implementations. 8 | 9 | See https://github.com/symfony/contracts/blob/main/README.md for more information. 10 | -------------------------------------------------------------------------------- /Cache/TagAwareCacheInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Cache; 13 | 14 | use Psr\Cache\InvalidArgumentException; 15 | 16 | /** 17 | * Allows invalidating cached items using tags. 18 | * 19 | * @author Nicolas Grekas 20 | */ 21 | interface TagAwareCacheInterface extends CacheInterface 22 | { 23 | /** 24 | * Invalidates cached items using tags. 25 | * 26 | * When implemented on a PSR-6 pool, invalidation should not apply 27 | * to deferred items. Instead, they should be committed as usual. 28 | * This allows replacing old tagged values by new ones without 29 | * race conditions. 30 | * 31 | * @param string[] $tags An array of tags to invalidate 32 | * 33 | * @return bool True on success 34 | * 35 | * @throws InvalidArgumentException When $tags is not valid 36 | */ 37 | public function invalidateTags(array $tags): bool; 38 | } 39 | -------------------------------------------------------------------------------- /Cache/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/cache-contracts", 3 | "type": "library", 4 | "description": "Generic abstractions related to caching", 5 | "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Nicolas Grekas", 11 | "email": "p@tchwork.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1", 20 | "psr/cache": "^3.0" 21 | }, 22 | "autoload": { 23 | "psr-4": { "Symfony\\Contracts\\Cache\\": "" } 24 | }, 25 | "minimum-stability": "dev", 26 | "extra": { 27 | "branch-alias": { 28 | "dev-main": "3.6-dev" 29 | }, 30 | "thanks": { 31 | "name": "symfony/contracts", 32 | "url": "https://github.com/symfony/contracts" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Deprecation/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | The changelog is maintained for all Symfony contracts at the following URL: 5 | https://github.com/symfony/contracts/blob/main/CHANGELOG.md 6 | -------------------------------------------------------------------------------- /Deprecation/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Deprecation/README.md: -------------------------------------------------------------------------------- 1 | Symfony Deprecation Contracts 2 | ============================= 3 | 4 | A generic function and convention to trigger deprecation notices. 5 | 6 | This package provides a single global function named `trigger_deprecation()` that triggers silenced deprecation notices. 7 | 8 | By using a custom PHP error handler such as the one provided by the Symfony ErrorHandler component, 9 | the triggered deprecations can be caught and logged for later discovery, both on dev and prod environments. 10 | 11 | The function requires at least 3 arguments: 12 | - the name of the Composer package that is triggering the deprecation 13 | - the version of the package that introduced the deprecation 14 | - the message of the deprecation 15 | - more arguments can be provided: they will be inserted in the message using `printf()` formatting 16 | 17 | Example: 18 | ```php 19 | trigger_deprecation('symfony/blockchain', '8.9', 'Using "%s" is deprecated, use "%s" instead.', 'bitcoin', 'fabcoin'); 20 | ``` 21 | 22 | This will generate the following message: 23 | `Since symfony/blockchain 8.9: Using "bitcoin" is deprecated, use "fabcoin" instead.` 24 | 25 | While not recommended, the deprecation notices can be completely ignored by declaring an empty 26 | `function trigger_deprecation() {}` in your application. 27 | -------------------------------------------------------------------------------- /Deprecation/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/deprecation-contracts", 3 | "type": "library", 4 | "description": "A generic function and convention to trigger deprecation notices", 5 | "homepage": "https://symfony.com", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Nicolas Grekas", 10 | "email": "p@tchwork.com" 11 | }, 12 | { 13 | "name": "Symfony Community", 14 | "homepage": "https://symfony.com/contributors" 15 | } 16 | ], 17 | "require": { 18 | "php": ">=8.1" 19 | }, 20 | "autoload": { 21 | "files": [ 22 | "function.php" 23 | ] 24 | }, 25 | "minimum-stability": "dev", 26 | "extra": { 27 | "branch-alias": { 28 | "dev-main": "3.6-dev" 29 | }, 30 | "thanks": { 31 | "name": "symfony/contracts", 32 | "url": "https://github.com/symfony/contracts" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Deprecation/function.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | if (!function_exists('trigger_deprecation')) { 13 | /** 14 | * Triggers a silenced deprecation notice. 15 | * 16 | * @param string $package The name of the Composer package that is triggering the deprecation 17 | * @param string $version The version of the package that introduced the deprecation 18 | * @param string $message The message of the deprecation 19 | * @param mixed ...$args Values to insert in the message using printf() formatting 20 | * 21 | * @author Nicolas Grekas 22 | */ 23 | function trigger_deprecation(string $package, string $version, string $message, mixed ...$args): void 24 | { 25 | @trigger_error(($package || $version ? "Since $package $version: " : '').($args ? vsprintf($message, $args) : $message), \E_USER_DEPRECATED); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /EventDispatcher/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | The changelog is maintained for all Symfony contracts at the following URL: 5 | https://github.com/symfony/contracts/blob/main/CHANGELOG.md 6 | -------------------------------------------------------------------------------- /EventDispatcher/Event.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\EventDispatcher; 13 | 14 | use Psr\EventDispatcher\StoppableEventInterface; 15 | 16 | /** 17 | * Event is the base class for classes containing event data. 18 | * 19 | * This class contains no event data. It is used by events that do not pass 20 | * state information to an event handler when an event is raised. 21 | * 22 | * You can call the method stopPropagation() to abort the execution of 23 | * further listeners in your event listener. 24 | * 25 | * @author Guilherme Blanco 26 | * @author Jonathan Wage 27 | * @author Roman Borschel 28 | * @author Bernhard Schussek 29 | * @author Nicolas Grekas 30 | */ 31 | class Event implements StoppableEventInterface 32 | { 33 | private bool $propagationStopped = false; 34 | 35 | public function isPropagationStopped(): bool 36 | { 37 | return $this->propagationStopped; 38 | } 39 | 40 | /** 41 | * Stops the propagation of the event to further event listeners. 42 | * 43 | * If multiple event listeners are connected to the same event, no 44 | * further event listener will be triggered once any trigger calls 45 | * stopPropagation(). 46 | */ 47 | public function stopPropagation(): void 48 | { 49 | $this->propagationStopped = true; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /EventDispatcher/EventDispatcherInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\EventDispatcher; 13 | 14 | use Psr\EventDispatcher\EventDispatcherInterface as PsrEventDispatcherInterface; 15 | 16 | /** 17 | * Allows providing hooks on domain-specific lifecycles by dispatching events. 18 | */ 19 | interface EventDispatcherInterface extends PsrEventDispatcherInterface 20 | { 21 | /** 22 | * Dispatches an event to all registered listeners. 23 | * 24 | * @template T of object 25 | * 26 | * @param T $event The event to pass to the event handlers/listeners 27 | * @param string|null $eventName The name of the event to dispatch. If not supplied, 28 | * the class of $event should be used instead. 29 | * 30 | * @return T The passed $event MUST be returned 31 | */ 32 | public function dispatch(object $event, ?string $eventName = null): object; 33 | } 34 | -------------------------------------------------------------------------------- /EventDispatcher/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /EventDispatcher/README.md: -------------------------------------------------------------------------------- 1 | Symfony EventDispatcher Contracts 2 | ================================= 3 | 4 | A set of abstractions extracted out of the Symfony components. 5 | 6 | Can be used to build on semantics that the Symfony components proved useful and 7 | that already have battle tested implementations. 8 | 9 | See https://github.com/symfony/contracts/blob/main/README.md for more information. 10 | -------------------------------------------------------------------------------- /EventDispatcher/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/event-dispatcher-contracts", 3 | "type": "library", 4 | "description": "Generic abstractions related to dispatching event", 5 | "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Nicolas Grekas", 11 | "email": "p@tchwork.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1", 20 | "psr/event-dispatcher": "^1" 21 | }, 22 | "autoload": { 23 | "psr-4": { "Symfony\\Contracts\\EventDispatcher\\": "" } 24 | }, 25 | "minimum-stability": "dev", 26 | "extra": { 27 | "branch-alias": { 28 | "dev-main": "3.6-dev" 29 | }, 30 | "thanks": { 31 | "name": "symfony/contracts", 32 | "url": "https://github.com/symfony/contracts" 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /HttpClient/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | The changelog is maintained for all Symfony contracts at the following URL: 5 | https://github.com/symfony/contracts/blob/main/CHANGELOG.md 6 | -------------------------------------------------------------------------------- /HttpClient/ChunkInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 15 | 16 | /** 17 | * The interface of chunks returned by ResponseStreamInterface::current(). 18 | * 19 | * When the chunk is first, last or timeout, the content MUST be empty. 20 | * When an unchecked timeout or a network error occurs, a TransportExceptionInterface 21 | * MUST be thrown by the destructor unless one was already thrown by another method. 22 | * 23 | * @author Nicolas Grekas 24 | */ 25 | interface ChunkInterface 26 | { 27 | /** 28 | * Tells when the idle timeout has been reached. 29 | * 30 | * @throws TransportExceptionInterface on a network error 31 | */ 32 | public function isTimeout(): bool; 33 | 34 | /** 35 | * Tells when headers just arrived. 36 | * 37 | * @throws TransportExceptionInterface on a network error or when the idle timeout is reached 38 | */ 39 | public function isFirst(): bool; 40 | 41 | /** 42 | * Tells when the body just completed. 43 | * 44 | * @throws TransportExceptionInterface on a network error or when the idle timeout is reached 45 | */ 46 | public function isLast(): bool; 47 | 48 | /** 49 | * Returns a [status code, headers] tuple when a 1xx status code was just received. 50 | * 51 | * @throws TransportExceptionInterface on a network error or when the idle timeout is reached 52 | */ 53 | public function getInformationalStatus(): ?array; 54 | 55 | /** 56 | * Returns the content of the response chunk. 57 | * 58 | * @throws TransportExceptionInterface on a network error or when the idle timeout is reached 59 | */ 60 | public function getContent(): string; 61 | 62 | /** 63 | * Returns the offset of the chunk in the response body. 64 | */ 65 | public function getOffset(): int; 66 | 67 | /** 68 | * In case of error, returns the message that describes it. 69 | */ 70 | public function getError(): ?string; 71 | } 72 | -------------------------------------------------------------------------------- /HttpClient/Exception/ClientExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When a 4xx response is returned. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface ClientExceptionInterface extends HttpExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /HttpClient/Exception/DecodingExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When a content-type cannot be decoded to the expected representation. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface DecodingExceptionInterface extends ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /HttpClient/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * The base interface for all exceptions in the contract. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface ExceptionInterface extends \Throwable 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /HttpClient/Exception/HttpExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | use Symfony\Contracts\HttpClient\ResponseInterface; 15 | 16 | /** 17 | * Base interface for HTTP-related exceptions. 18 | * 19 | * @author Anton Chernikov 20 | */ 21 | interface HttpExceptionInterface extends ExceptionInterface 22 | { 23 | public function getResponse(): ResponseInterface; 24 | } 25 | -------------------------------------------------------------------------------- /HttpClient/Exception/RedirectionExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When a 3xx response is returned and the "max_redirects" option has been reached. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface RedirectionExceptionInterface extends HttpExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /HttpClient/Exception/ServerExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When a 5xx response is returned. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface ServerExceptionInterface extends HttpExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /HttpClient/Exception/TimeoutExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When an idle timeout occurs. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface TimeoutExceptionInterface extends TransportExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /HttpClient/Exception/TransportExceptionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Exception; 13 | 14 | /** 15 | * When any error happens at the transport level. 16 | * 17 | * @author Nicolas Grekas 18 | */ 19 | interface TransportExceptionInterface extends ExceptionInterface 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /HttpClient/HttpClientInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 15 | use Symfony\Contracts\HttpClient\Test\HttpClientTestCase; 16 | 17 | /** 18 | * Provides flexible methods for requesting HTTP resources synchronously or asynchronously. 19 | * 20 | * @see HttpClientTestCase for a reference test suite 21 | * 22 | * @author Nicolas Grekas 23 | */ 24 | interface HttpClientInterface 25 | { 26 | public const OPTIONS_DEFAULTS = [ 27 | 'auth_basic' => null, // array|string - an array containing the username as first value, and optionally the 28 | // password as the second one; or string like username:password - enabling HTTP Basic 29 | // authentication (RFC 7617) 30 | 'auth_bearer' => null, // string - a token enabling HTTP Bearer authorization (RFC 6750) 31 | 'query' => [], // string[] - associative array of query string values to merge with the request's URL 32 | 'headers' => [], // iterable|string[]|string[][] - headers names provided as keys or as part of values 33 | 'body' => '', // array|string|resource|\Traversable|\Closure - the callback SHOULD yield a string 34 | // smaller than the amount requested as argument; the empty string signals EOF; if 35 | // an array is passed, it is meant as a form payload of field names and values 36 | 'json' => null, // mixed - if set, implementations MUST set the "body" option to the JSON-encoded 37 | // value and set the "content-type" header to a JSON-compatible value if it is not 38 | // explicitly defined in the headers option - typically "application/json" 39 | 'user_data' => null, // mixed - any extra data to attach to the request (scalar, callable, object...) that 40 | // MUST be available via $response->getInfo('user_data') - not used internally 41 | 'max_redirects' => 20, // int - the maximum number of redirects to follow; a value lower than or equal to 0 42 | // means redirects should not be followed; "Authorization" and "Cookie" headers MUST 43 | // NOT follow except for the initial host name 44 | 'http_version' => null, // string - defaults to the best supported version, typically 1.1 or 2.0 45 | 'base_uri' => null, // string - the URI to resolve relative URLs, following rules in RFC 3986, section 2 46 | 'buffer' => true, // bool|resource|\Closure - whether the content of the response should be buffered or not, 47 | // or a stream resource where the response body should be written, 48 | // or a closure telling if/where the response should be buffered based on its headers 49 | 'on_progress' => null, // callable(int $dlNow, int $dlSize, array $info) - throwing any exceptions MUST abort the 50 | // request; it MUST be called on connection, on headers and on completion; it SHOULD be 51 | // called on upload/download of data and at least 1/s 52 | 'resolve' => [], // string[] - a map of host to IP address that SHOULD replace DNS resolution 53 | 'proxy' => null, // string - by default, the proxy-related env vars handled by curl SHOULD be honored 54 | 'no_proxy' => null, // string - a comma separated list of hosts that do not require a proxy to be reached 55 | 'timeout' => null, // float - the idle timeout (in seconds) - defaults to ini_get('default_socket_timeout') 56 | 'max_duration' => 0, // float - the maximum execution time (in seconds) for the request+response as a whole; 57 | // a value lower than or equal to 0 means it is unlimited 58 | 'bindto' => '0', // string - the interface or the local socket to bind to 59 | 'verify_peer' => true, // see https://php.net/context.ssl for the following options 60 | 'verify_host' => true, 61 | 'cafile' => null, 62 | 'capath' => null, 63 | 'local_cert' => null, 64 | 'local_pk' => null, 65 | 'passphrase' => null, 66 | 'ciphers' => null, 67 | 'peer_fingerprint' => null, 68 | 'capture_peer_cert_chain' => false, 69 | 'crypto_method' => \STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT, // STREAM_CRYPTO_METHOD_TLSv*_CLIENT - minimum TLS version 70 | 'extra' => [], // array - additional options that can be ignored if unsupported, unlike regular options 71 | ]; 72 | 73 | /** 74 | * Requests an HTTP resource. 75 | * 76 | * Responses MUST be lazy, but their status code MUST be 77 | * checked even if none of their public methods are called. 78 | * 79 | * Implementations are not required to support all options described above; they can also 80 | * support more custom options; but in any case, they MUST throw a TransportExceptionInterface 81 | * when an unsupported option is passed. 82 | * 83 | * @throws TransportExceptionInterface When an unsupported option is passed 84 | */ 85 | public function request(string $method, string $url, array $options = []): ResponseInterface; 86 | 87 | /** 88 | * Yields responses chunk by chunk as they complete. 89 | * 90 | * @param ResponseInterface|iterable $responses One or more responses created by the current HTTP client 91 | * @param float|null $timeout The idle timeout before yielding timeout chunks 92 | */ 93 | public function stream(ResponseInterface|iterable $responses, ?float $timeout = null): ResponseStreamInterface; 94 | 95 | /** 96 | * Returns a new instance of the client with new default options. 97 | */ 98 | public function withOptions(array $options): static; 99 | } 100 | -------------------------------------------------------------------------------- /HttpClient/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /HttpClient/README.md: -------------------------------------------------------------------------------- 1 | Symfony HttpClient Contracts 2 | ============================ 3 | 4 | A set of abstractions extracted out of the Symfony components. 5 | 6 | Can be used to build on semantics that the Symfony components proved useful and 7 | that already have battle tested implementations. 8 | 9 | See https://github.com/symfony/contracts/blob/main/README.md for more information. 10 | -------------------------------------------------------------------------------- /HttpClient/ResponseInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient; 13 | 14 | use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; 15 | use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; 16 | use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; 17 | use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; 18 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 19 | 20 | /** 21 | * A (lazily retrieved) HTTP response. 22 | * 23 | * @author Nicolas Grekas 24 | */ 25 | interface ResponseInterface 26 | { 27 | /** 28 | * Gets the HTTP status code of the response. 29 | * 30 | * @throws TransportExceptionInterface when a network error occurs 31 | */ 32 | public function getStatusCode(): int; 33 | 34 | /** 35 | * Gets the HTTP headers of the response. 36 | * 37 | * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes 38 | * 39 | * @return array> The headers of the response keyed by header names in lowercase 40 | * 41 | * @throws TransportExceptionInterface When a network error occurs 42 | * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached 43 | * @throws ClientExceptionInterface On a 4xx when $throw is true 44 | * @throws ServerExceptionInterface On a 5xx when $throw is true 45 | */ 46 | public function getHeaders(bool $throw = true): array; 47 | 48 | /** 49 | * Gets the response body as a string. 50 | * 51 | * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes 52 | * 53 | * @throws TransportExceptionInterface When a network error occurs 54 | * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached 55 | * @throws ClientExceptionInterface On a 4xx when $throw is true 56 | * @throws ServerExceptionInterface On a 5xx when $throw is true 57 | */ 58 | public function getContent(bool $throw = true): string; 59 | 60 | /** 61 | * Gets the response body decoded as array, typically from a JSON payload. 62 | * 63 | * @param bool $throw Whether an exception should be thrown on 3/4/5xx status codes 64 | * 65 | * @throws DecodingExceptionInterface When the body cannot be decoded to an array 66 | * @throws TransportExceptionInterface When a network error occurs 67 | * @throws RedirectionExceptionInterface On a 3xx when $throw is true and the "max_redirects" option has been reached 68 | * @throws ClientExceptionInterface On a 4xx when $throw is true 69 | * @throws ServerExceptionInterface On a 5xx when $throw is true 70 | */ 71 | public function toArray(bool $throw = true): array; 72 | 73 | /** 74 | * Closes the response stream and all related buffers. 75 | * 76 | * No further chunk will be yielded after this method has been called. 77 | */ 78 | public function cancel(): void; 79 | 80 | /** 81 | * Returns info coming from the transport layer. 82 | * 83 | * This method SHOULD NOT throw any ExceptionInterface and SHOULD be non-blocking. 84 | * The returned info is "live": it can be empty and can change from one call to 85 | * another, as the request/response progresses. 86 | * 87 | * The following info MUST be returned: 88 | * - canceled (bool) - true if the response was canceled using ResponseInterface::cancel(), false otherwise 89 | * - error (string|null) - the error message when the transfer was aborted, null otherwise 90 | * - http_code (int) - the last response code or 0 when it is not known yet 91 | * - http_method (string) - the HTTP verb of the last request 92 | * - redirect_count (int) - the number of redirects followed while executing the request 93 | * - redirect_url (string|null) - the resolved location of redirect responses, null otherwise 94 | * - response_headers (array) - an array modelled after the special $http_response_header variable 95 | * - start_time (float) - the time when the request was sent or 0.0 when it's pending 96 | * - url (string) - the last effective URL of the request 97 | * - user_data (mixed) - the value of the "user_data" request option, null if not set 98 | * 99 | * When the "capture_peer_cert_chain" option is true, the "peer_certificate_chain" 100 | * attribute SHOULD list the peer certificates as an array of OpenSSL X.509 resources. 101 | * 102 | * Other info SHOULD be named after curl_getinfo()'s associative return value. 103 | * 104 | * @return mixed An array of all available info, or one of them when $type is 105 | * provided, or null when an unsupported type is requested 106 | */ 107 | public function getInfo(?string $type = null): mixed; 108 | } 109 | -------------------------------------------------------------------------------- /HttpClient/ResponseStreamInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient; 13 | 14 | /** 15 | * Yields response chunks, returned by HttpClientInterface::stream(). 16 | * 17 | * @author Nicolas Grekas 18 | * 19 | * @extends \Iterator 20 | */ 21 | interface ResponseStreamInterface extends \Iterator 22 | { 23 | public function key(): ResponseInterface; 24 | 25 | public function current(): ChunkInterface; 26 | } 27 | -------------------------------------------------------------------------------- /HttpClient/Test/Fixtures/web/index.php: -------------------------------------------------------------------------------- 1 | $v) { 33 | if (str_starts_with($k, 'HTTP_')) { 34 | $vars[$k] = $v; 35 | } 36 | } 37 | 38 | $json = json_encode($vars, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); 39 | 40 | switch (parse_url($vars['REQUEST_URI'], \PHP_URL_PATH)) { 41 | default: 42 | exit; 43 | 44 | case '/head': 45 | header('X-Request-Vars: '.json_encode($vars, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE)); 46 | header('Content-Length: '.strlen($json), true); 47 | break; 48 | 49 | case '/': 50 | case '/?a=a&b=b': 51 | case 'http://127.0.0.1:8057/': 52 | case 'http://localhost:8057/': 53 | ob_start('ob_gzhandler'); 54 | break; 55 | 56 | case '/103': 57 | header('HTTP/1.1 103 Early Hints'); 58 | header('Link: ; rel=preload; as=style', false); 59 | header('Link: ; rel=preload; as=script', false); 60 | flush(); 61 | usleep(1000); 62 | echo "HTTP/1.1 200 OK\r\n"; 63 | echo "Date: Fri, 26 May 2017 10:02:11 GMT\r\n"; 64 | echo "Content-Length: 13\r\n"; 65 | echo "\r\n"; 66 | echo 'Here the body'; 67 | exit; 68 | 69 | case '/404': 70 | header('Content-Type: application/json', true, 404); 71 | break; 72 | 73 | case '/404-gzipped': 74 | header('Content-Type: text/plain', true, 404); 75 | ob_start('ob_gzhandler'); 76 | @ob_flush(); 77 | flush(); 78 | usleep(300000); 79 | echo 'some text'; 80 | exit; 81 | 82 | case '/301': 83 | if ('Basic Zm9vOmJhcg==' === $vars['HTTP_AUTHORIZATION']) { 84 | header('Location: http://127.0.0.1:8057/302', true, 301); 85 | } 86 | break; 87 | 88 | case '/301/bad-tld': 89 | header('Location: http://foo.example.', true, 301); 90 | break; 91 | 92 | case '/301/invalid': 93 | header('Location: //?foo=bar', true, 301); 94 | break; 95 | 96 | case '/301/proxy': 97 | case 'http://localhost:8057/301/proxy': 98 | case 'http://127.0.0.1:8057/301/proxy': 99 | header('Location: http://localhost:8057/', true, 301); 100 | break; 101 | 102 | case '/302': 103 | if (!isset($vars['HTTP_AUTHORIZATION'])) { 104 | $location = $_GET['location'] ?? 'http://localhost:8057/'; 105 | header('Location: '.$location, true, 302); 106 | } 107 | break; 108 | 109 | case '/302/relative': 110 | header('Location: ..', true, 302); 111 | break; 112 | 113 | case '/304': 114 | header('Content-Length: 10', true, 304); 115 | echo '12345'; 116 | 117 | return; 118 | 119 | case '/307': 120 | header('Location: http://localhost:8057/post', true, 307); 121 | break; 122 | 123 | case '/length-broken': 124 | header('Content-Length: 1000'); 125 | break; 126 | 127 | case '/post': 128 | $output = json_encode($_POST + ['REQUEST_METHOD' => $vars['REQUEST_METHOD']], \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE); 129 | header('Content-Type: application/json', true); 130 | header('Content-Length: '.strlen($output)); 131 | echo $output; 132 | exit; 133 | 134 | case '/timeout-header': 135 | usleep(300000); 136 | break; 137 | 138 | case '/timeout-body': 139 | echo '<1>'; 140 | @ob_flush(); 141 | flush(); 142 | usleep(500000); 143 | echo '<2>'; 144 | exit; 145 | 146 | case '/timeout-long': 147 | ignore_user_abort(false); 148 | sleep(1); 149 | while (true) { 150 | echo '<1>'; 151 | @ob_flush(); 152 | flush(); 153 | usleep(500); 154 | } 155 | exit; 156 | 157 | case '/chunked': 158 | header('Transfer-Encoding: chunked'); 159 | echo "8\r\nSymfony \r\n5\r\nis aw\r\n6\r\nesome!\r\n0\r\n\r\n"; 160 | exit; 161 | 162 | case '/chunked-broken': 163 | header('Transfer-Encoding: chunked'); 164 | echo "8\r\nSymfony \r\n5\r\nis aw\r\n6\r\ne"; 165 | exit; 166 | 167 | case '/gzip-broken': 168 | header('Content-Encoding: gzip'); 169 | echo str_repeat('-', 1000); 170 | exit; 171 | 172 | case '/max-duration': 173 | ignore_user_abort(false); 174 | while (true) { 175 | echo '<1>'; 176 | @ob_flush(); 177 | flush(); 178 | usleep(500); 179 | } 180 | exit; 181 | 182 | case '/json': 183 | header('Content-Type: application/json'); 184 | echo json_encode([ 185 | 'documents' => [ 186 | ['id' => '/json/1'], 187 | ['id' => '/json/2'], 188 | ['id' => '/json/3'], 189 | ], 190 | ]); 191 | exit; 192 | 193 | case '/json/1': 194 | case '/json/2': 195 | case '/json/3': 196 | header('Content-Type: application/json'); 197 | echo json_encode([ 198 | 'title' => $vars['REQUEST_URI'], 199 | ]); 200 | 201 | exit; 202 | 203 | case '/custom': 204 | if (isset($_GET['status'])) { 205 | http_response_code((int) $_GET['status']); 206 | } 207 | if (isset($_GET['headers']) && is_array($_GET['headers'])) { 208 | foreach ($_GET['headers'] as $header) { 209 | header($header); 210 | } 211 | } 212 | } 213 | 214 | header('Content-Type: application/json', true); 215 | 216 | echo $json; 217 | -------------------------------------------------------------------------------- /HttpClient/Test/HttpClientTestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Test; 13 | 14 | use PHPUnit\Framework\Attributes\RequiresPhpExtension; 15 | use PHPUnit\Framework\TestCase; 16 | use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; 17 | use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; 18 | use Symfony\Contracts\HttpClient\Exception\TimeoutExceptionInterface; 19 | use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; 20 | use Symfony\Contracts\HttpClient\HttpClientInterface; 21 | 22 | /** 23 | * A reference test suite for HttpClientInterface implementations. 24 | */ 25 | abstract class HttpClientTestCase extends TestCase 26 | { 27 | public static function setUpBeforeClass(): void 28 | { 29 | if (!\function_exists('ob_gzhandler')) { 30 | static::markTestSkipped('The "ob_gzhandler" function is not available.'); 31 | } 32 | 33 | TestHttpServer::start(); 34 | } 35 | 36 | public static function tearDownAfterClass(): void 37 | { 38 | TestHttpServer::stop(8067); 39 | TestHttpServer::stop(8077); 40 | } 41 | 42 | abstract protected function getHttpClient(string $testCase): HttpClientInterface; 43 | 44 | public function testGetRequest() 45 | { 46 | $client = $this->getHttpClient(__FUNCTION__); 47 | $response = $client->request('GET', 'http://localhost:8057', [ 48 | 'headers' => ['Foo' => 'baR'], 49 | 'user_data' => $data = new \stdClass(), 50 | ]); 51 | 52 | $this->assertSame([], $response->getInfo('response_headers')); 53 | $this->assertSame($data, $response->getInfo()['user_data']); 54 | $this->assertSame(200, $response->getStatusCode()); 55 | 56 | $info = $response->getInfo(); 57 | $this->assertNull($info['error']); 58 | $this->assertSame(0, $info['redirect_count']); 59 | $this->assertSame('HTTP/1.1 200 OK', $info['response_headers'][0]); 60 | $this->assertSame('Host: localhost:8057', $info['response_headers'][1]); 61 | $this->assertSame('http://localhost:8057/', $info['url']); 62 | 63 | $headers = $response->getHeaders(); 64 | 65 | $this->assertSame('localhost:8057', $headers['host'][0]); 66 | $this->assertSame(['application/json'], $headers['content-type']); 67 | 68 | $body = json_decode($response->getContent(), true); 69 | $this->assertSame($body, $response->toArray()); 70 | 71 | $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); 72 | $this->assertSame('/', $body['REQUEST_URI']); 73 | $this->assertSame('GET', $body['REQUEST_METHOD']); 74 | $this->assertSame('localhost:8057', $body['HTTP_HOST']); 75 | $this->assertSame('baR', $body['HTTP_FOO']); 76 | 77 | $response = $client->request('GET', 'http://localhost:8057/length-broken'); 78 | 79 | $this->expectException(TransportExceptionInterface::class); 80 | $response->getContent(); 81 | } 82 | 83 | public function testHeadRequest() 84 | { 85 | $client = $this->getHttpClient(__FUNCTION__); 86 | $response = $client->request('HEAD', 'http://localhost:8057/head', [ 87 | 'headers' => ['Foo' => 'baR'], 88 | 'user_data' => $data = new \stdClass(), 89 | 'buffer' => false, 90 | ]); 91 | 92 | $this->assertSame([], $response->getInfo('response_headers')); 93 | $this->assertSame(200, $response->getStatusCode()); 94 | 95 | $info = $response->getInfo(); 96 | $this->assertSame('HTTP/1.1 200 OK', $info['response_headers'][0]); 97 | $this->assertSame('Host: localhost:8057', $info['response_headers'][1]); 98 | 99 | $headers = $response->getHeaders(); 100 | 101 | $this->assertSame('localhost:8057', $headers['host'][0]); 102 | $this->assertSame(['application/json'], $headers['content-type']); 103 | $this->assertTrue(0 < $headers['content-length'][0]); 104 | 105 | $this->assertSame('', $response->getContent()); 106 | } 107 | 108 | public function testNonBufferedGetRequest() 109 | { 110 | $client = $this->getHttpClient(__FUNCTION__); 111 | $response = $client->request('GET', 'http://localhost:8057', [ 112 | 'buffer' => false, 113 | 'headers' => ['Foo' => 'baR'], 114 | ]); 115 | 116 | $body = $response->toArray(); 117 | $this->assertSame('baR', $body['HTTP_FOO']); 118 | 119 | $this->expectException(TransportExceptionInterface::class); 120 | $response->getContent(); 121 | } 122 | 123 | public function testBufferSink() 124 | { 125 | $sink = fopen('php://temp', 'w+'); 126 | $client = $this->getHttpClient(__FUNCTION__); 127 | $response = $client->request('GET', 'http://localhost:8057', [ 128 | 'buffer' => $sink, 129 | 'headers' => ['Foo' => 'baR'], 130 | ]); 131 | 132 | $body = $response->toArray(); 133 | $this->assertSame('baR', $body['HTTP_FOO']); 134 | 135 | rewind($sink); 136 | $sink = stream_get_contents($sink); 137 | $this->assertSame($sink, $response->getContent()); 138 | } 139 | 140 | public function testConditionalBuffering() 141 | { 142 | $client = $this->getHttpClient(__FUNCTION__); 143 | $response = $client->request('GET', 'http://localhost:8057'); 144 | $firstContent = $response->getContent(); 145 | $secondContent = $response->getContent(); 146 | 147 | $this->assertSame($firstContent, $secondContent); 148 | 149 | $response = $client->request('GET', 'http://localhost:8057', ['buffer' => fn () => false]); 150 | $response->getContent(); 151 | 152 | $this->expectException(TransportExceptionInterface::class); 153 | $response->getContent(); 154 | } 155 | 156 | public function testReentrantBufferCallback() 157 | { 158 | $client = $this->getHttpClient(__FUNCTION__); 159 | 160 | $response = $client->request('GET', 'http://localhost:8057', ['buffer' => function () use (&$response) { 161 | $response->cancel(); 162 | 163 | return true; 164 | }]); 165 | 166 | $this->assertSame(200, $response->getStatusCode()); 167 | 168 | $this->expectException(TransportExceptionInterface::class); 169 | $response->getContent(); 170 | } 171 | 172 | public function testThrowingBufferCallback() 173 | { 174 | $client = $this->getHttpClient(__FUNCTION__); 175 | 176 | $response = $client->request('GET', 'http://localhost:8057', ['buffer' => function () { 177 | throw new \Exception('Boo.'); 178 | }]); 179 | 180 | $this->assertSame(200, $response->getStatusCode()); 181 | 182 | $this->expectException(TransportExceptionInterface::class); 183 | $this->expectExceptionMessage('Boo'); 184 | $response->getContent(); 185 | } 186 | 187 | public function testUnsupportedOption() 188 | { 189 | $client = $this->getHttpClient(__FUNCTION__); 190 | 191 | $this->expectException(\InvalidArgumentException::class); 192 | $client->request('GET', 'http://localhost:8057', [ 193 | 'capture_peer_cert' => 1.0, 194 | ]); 195 | } 196 | 197 | public function testHttpVersion() 198 | { 199 | $client = $this->getHttpClient(__FUNCTION__); 200 | $response = $client->request('GET', 'http://localhost:8057', [ 201 | 'http_version' => 1.0, 202 | ]); 203 | 204 | $this->assertSame(200, $response->getStatusCode()); 205 | $this->assertSame('HTTP/1.0 200 OK', $response->getInfo('response_headers')[0]); 206 | 207 | $body = $response->toArray(); 208 | 209 | $this->assertSame('HTTP/1.0', $body['SERVER_PROTOCOL']); 210 | $this->assertSame('GET', $body['REQUEST_METHOD']); 211 | $this->assertSame('/', $body['REQUEST_URI']); 212 | } 213 | 214 | public function testChunkedEncoding() 215 | { 216 | $client = $this->getHttpClient(__FUNCTION__); 217 | $response = $client->request('GET', 'http://localhost:8057/chunked'); 218 | 219 | $this->assertSame(['chunked'], $response->getHeaders()['transfer-encoding']); 220 | $this->assertSame('Symfony is awesome!', $response->getContent()); 221 | 222 | $response = $client->request('GET', 'http://localhost:8057/chunked-broken'); 223 | 224 | $this->expectException(TransportExceptionInterface::class); 225 | $response->getContent(); 226 | } 227 | 228 | public function testClientError() 229 | { 230 | $client = $this->getHttpClient(__FUNCTION__); 231 | $response = $client->request('GET', 'http://localhost:8057/404'); 232 | 233 | $client->stream($response)->valid(); 234 | 235 | $this->assertSame(404, $response->getInfo('http_code')); 236 | 237 | try { 238 | $response->getHeaders(); 239 | $this->fail(ClientExceptionInterface::class.' expected'); 240 | } catch (ClientExceptionInterface) { 241 | } 242 | 243 | try { 244 | $response->getContent(); 245 | $this->fail(ClientExceptionInterface::class.' expected'); 246 | } catch (ClientExceptionInterface) { 247 | } 248 | 249 | $this->assertSame(404, $response->getStatusCode()); 250 | $this->assertSame(['application/json'], $response->getHeaders(false)['content-type']); 251 | $this->assertNotEmpty($response->getContent(false)); 252 | 253 | $response = $client->request('GET', 'http://localhost:8057/404'); 254 | 255 | try { 256 | foreach ($client->stream($response) as $chunk) { 257 | $this->assertTrue($chunk->isFirst()); 258 | } 259 | $this->fail(ClientExceptionInterface::class.' expected'); 260 | } catch (ClientExceptionInterface) { 261 | } 262 | } 263 | 264 | public function testIgnoreErrors() 265 | { 266 | $client = $this->getHttpClient(__FUNCTION__); 267 | $response = $client->request('GET', 'http://localhost:8057/404'); 268 | 269 | $this->assertSame(404, $response->getStatusCode()); 270 | } 271 | 272 | public function testDnsError() 273 | { 274 | $client = $this->getHttpClient(__FUNCTION__); 275 | $response = $client->request('GET', 'http://localhost:8057/301/bad-tld'); 276 | 277 | try { 278 | $response->getStatusCode(); 279 | $this->fail(TransportExceptionInterface::class.' expected'); 280 | } catch (TransportExceptionInterface) { 281 | $this->addToAssertionCount(1); 282 | } 283 | 284 | try { 285 | $response->getStatusCode(); 286 | $this->fail(TransportExceptionInterface::class.' still expected'); 287 | } catch (TransportExceptionInterface) { 288 | $this->addToAssertionCount(1); 289 | } 290 | 291 | $response = $client->request('GET', 'http://localhost:8057/301/bad-tld'); 292 | 293 | try { 294 | foreach ($client->stream($response) as $r => $chunk) { 295 | } 296 | $this->fail(TransportExceptionInterface::class.' expected'); 297 | } catch (TransportExceptionInterface) { 298 | $this->addToAssertionCount(1); 299 | } 300 | 301 | $this->assertSame($response, $r); 302 | $this->assertNotNull($chunk->getError()); 303 | 304 | $this->expectException(TransportExceptionInterface::class); 305 | foreach ($client->stream($response) as $chunk) { 306 | } 307 | } 308 | 309 | public function testInlineAuth() 310 | { 311 | $client = $this->getHttpClient(__FUNCTION__); 312 | $response = $client->request('GET', 'http://foo:bar%3Dbar@localhost:8057'); 313 | 314 | $body = $response->toArray(); 315 | 316 | $this->assertSame('foo', $body['PHP_AUTH_USER']); 317 | $this->assertSame('bar=bar', $body['PHP_AUTH_PW']); 318 | } 319 | 320 | public function testBadRequestBody() 321 | { 322 | $client = $this->getHttpClient(__FUNCTION__); 323 | 324 | $this->expectException(TransportExceptionInterface::class); 325 | 326 | $response = $client->request('POST', 'http://localhost:8057/', [ 327 | 'body' => function () { yield []; }, 328 | ]); 329 | 330 | $response->getStatusCode(); 331 | } 332 | 333 | public function test304() 334 | { 335 | $client = $this->getHttpClient(__FUNCTION__); 336 | $response = $client->request('GET', 'http://localhost:8057/304', [ 337 | 'headers' => ['If-Match' => '"abc"'], 338 | 'buffer' => false, 339 | ]); 340 | 341 | $this->assertSame(304, $response->getStatusCode()); 342 | $this->assertSame('', $response->getContent(false)); 343 | } 344 | 345 | /** 346 | * @testWith [[]] 347 | * [["Content-Length: 7"]] 348 | */ 349 | public function testRedirects(array $headers = []) 350 | { 351 | $client = $this->getHttpClient(__FUNCTION__); 352 | $response = $client->request('POST', 'http://localhost:8057/301', [ 353 | 'auth_basic' => 'foo:bar', 354 | 'headers' => $headers, 355 | 'body' => function () { 356 | yield 'foo=bar'; 357 | }, 358 | ]); 359 | 360 | $body = $response->toArray(); 361 | $this->assertSame('GET', $body['REQUEST_METHOD']); 362 | $this->assertSame('Basic Zm9vOmJhcg==', $body['HTTP_AUTHORIZATION']); 363 | $this->assertSame('http://localhost:8057/', $response->getInfo('url')); 364 | 365 | $this->assertSame(2, $response->getInfo('redirect_count')); 366 | $this->assertNull($response->getInfo('redirect_url')); 367 | 368 | $expected = [ 369 | 'HTTP/1.1 301 Moved Permanently', 370 | 'Location: http://127.0.0.1:8057/302', 371 | 'Content-Type: application/json', 372 | 'HTTP/1.1 302 Found', 373 | 'Location: http://localhost:8057/', 374 | 'Content-Type: application/json', 375 | 'HTTP/1.1 200 OK', 376 | 'Content-Type: application/json', 377 | ]; 378 | 379 | $filteredHeaders = array_values(array_filter($response->getInfo('response_headers'), function ($h) { 380 | return \in_array(substr($h, 0, 4), ['HTTP', 'Loca', 'Cont'], true) && 'Content-Encoding: gzip' !== $h; 381 | })); 382 | 383 | $this->assertSame($expected, $filteredHeaders); 384 | } 385 | 386 | public function testInvalidRedirect() 387 | { 388 | $client = $this->getHttpClient(__FUNCTION__); 389 | $response = $client->request('GET', 'http://localhost:8057/301/invalid'); 390 | 391 | $this->assertSame(301, $response->getStatusCode()); 392 | $this->assertSame(['//?foo=bar'], $response->getHeaders(false)['location']); 393 | $this->assertSame(0, $response->getInfo('redirect_count')); 394 | $this->assertNull($response->getInfo('redirect_url')); 395 | 396 | $this->expectException(RedirectionExceptionInterface::class); 397 | $response->getHeaders(); 398 | } 399 | 400 | public function testRelativeRedirects() 401 | { 402 | $client = $this->getHttpClient(__FUNCTION__); 403 | $response = $client->request('GET', 'http://localhost:8057/302/relative'); 404 | 405 | $body = $response->toArray(); 406 | 407 | $this->assertSame('/', $body['REQUEST_URI']); 408 | $this->assertNull($response->getInfo('redirect_url')); 409 | 410 | $response = $client->request('GET', 'http://localhost:8057/302/relative', [ 411 | 'max_redirects' => 0, 412 | ]); 413 | 414 | $this->assertSame(302, $response->getStatusCode()); 415 | $this->assertSame('http://localhost:8057/', $response->getInfo('redirect_url')); 416 | } 417 | 418 | public function testRedirect307() 419 | { 420 | $client = $this->getHttpClient(__FUNCTION__); 421 | 422 | $response = $client->request('POST', 'http://localhost:8057/307', [ 423 | 'body' => function () { 424 | yield 'foo=bar'; 425 | }, 426 | 'max_redirects' => 0, 427 | ]); 428 | 429 | $this->assertSame(307, $response->getStatusCode()); 430 | 431 | $response = $client->request('POST', 'http://localhost:8057/307', [ 432 | 'body' => 'foo=bar', 433 | ]); 434 | 435 | $body = $response->toArray(); 436 | 437 | $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $body); 438 | } 439 | 440 | public function testMaxRedirects() 441 | { 442 | $client = $this->getHttpClient(__FUNCTION__); 443 | $response = $client->request('GET', 'http://localhost:8057/301', [ 444 | 'max_redirects' => 1, 445 | 'auth_basic' => 'foo:bar', 446 | ]); 447 | 448 | try { 449 | $response->getHeaders(); 450 | $this->fail(RedirectionExceptionInterface::class.' expected'); 451 | } catch (RedirectionExceptionInterface) { 452 | } 453 | 454 | $this->assertSame(302, $response->getStatusCode()); 455 | $this->assertSame(1, $response->getInfo('redirect_count')); 456 | $this->assertSame('http://localhost:8057/', $response->getInfo('redirect_url')); 457 | 458 | $expected = [ 459 | 'HTTP/1.1 301 Moved Permanently', 460 | 'Location: http://127.0.0.1:8057/302', 461 | 'Content-Type: application/json', 462 | 'HTTP/1.1 302 Found', 463 | 'Location: http://localhost:8057/', 464 | 'Content-Type: application/json', 465 | ]; 466 | 467 | $filteredHeaders = array_values(array_filter($response->getInfo('response_headers'), function ($h) { 468 | return \in_array(substr($h, 0, 4), ['HTTP', 'Loca', 'Cont'], true); 469 | })); 470 | 471 | $this->assertSame($expected, $filteredHeaders); 472 | } 473 | 474 | public function testStream() 475 | { 476 | $client = $this->getHttpClient(__FUNCTION__); 477 | 478 | $response = $client->request('GET', 'http://localhost:8057'); 479 | $chunks = $client->stream($response); 480 | $result = []; 481 | 482 | foreach ($chunks as $r => $chunk) { 483 | if ($chunk->isTimeout()) { 484 | $result[] = 't'; 485 | } elseif ($chunk->isLast()) { 486 | $result[] = 'l'; 487 | } elseif ($chunk->isFirst()) { 488 | $result[] = 'f'; 489 | } 490 | } 491 | 492 | $this->assertSame($response, $r); 493 | $this->assertSame(['f', 'l'], $result); 494 | 495 | $chunk = null; 496 | $i = 0; 497 | 498 | foreach ($client->stream($response) as $chunk) { 499 | ++$i; 500 | } 501 | 502 | $this->assertSame(1, $i); 503 | $this->assertTrue($chunk->isLast()); 504 | } 505 | 506 | public function testAddToStream() 507 | { 508 | $client = $this->getHttpClient(__FUNCTION__); 509 | 510 | $r1 = $client->request('GET', 'http://localhost:8057'); 511 | 512 | $completed = []; 513 | 514 | $pool = [$r1]; 515 | 516 | while ($pool) { 517 | $chunks = $client->stream($pool); 518 | $pool = []; 519 | 520 | foreach ($chunks as $r => $chunk) { 521 | if (!$chunk->isLast()) { 522 | continue; 523 | } 524 | 525 | if ($r1 === $r) { 526 | $r2 = $client->request('GET', 'http://localhost:8057'); 527 | $pool[] = $r2; 528 | } 529 | 530 | $completed[] = $r; 531 | } 532 | } 533 | 534 | $this->assertSame([$r1, $r2], $completed); 535 | } 536 | 537 | public function testCompleteTypeError() 538 | { 539 | $client = $this->getHttpClient(__FUNCTION__); 540 | 541 | $this->expectException(\TypeError::class); 542 | $client->stream(123); 543 | } 544 | 545 | public function testOnProgress() 546 | { 547 | $client = $this->getHttpClient(__FUNCTION__); 548 | $response = $client->request('POST', 'http://localhost:8057/post', [ 549 | 'headers' => ['Content-Length' => 14], 550 | 'body' => 'foo=0123456789', 551 | 'on_progress' => function (...$state) use (&$steps) { $steps[] = $state; }, 552 | ]); 553 | 554 | $body = $response->toArray(); 555 | 556 | $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); 557 | $this->assertSame([0, 0], \array_slice($steps[0], 0, 2)); 558 | $lastStep = \array_slice($steps, -1)[0]; 559 | $this->assertSame([57, 57], \array_slice($lastStep, 0, 2)); 560 | $this->assertSame('http://localhost:8057/post', $steps[0][2]['url']); 561 | } 562 | 563 | public function testPostJson() 564 | { 565 | $client = $this->getHttpClient(__FUNCTION__); 566 | 567 | $response = $client->request('POST', 'http://localhost:8057/post', [ 568 | 'json' => ['foo' => 'bar'], 569 | ]); 570 | 571 | $body = $response->toArray(); 572 | 573 | $this->assertStringContainsString('json', $body['content-type']); 574 | unset($body['content-type']); 575 | $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $body); 576 | } 577 | 578 | public function testPostArray() 579 | { 580 | $client = $this->getHttpClient(__FUNCTION__); 581 | 582 | $response = $client->request('POST', 'http://localhost:8057/post', [ 583 | 'body' => ['foo' => 'bar'], 584 | ]); 585 | 586 | $this->assertSame(['foo' => 'bar', 'REQUEST_METHOD' => 'POST'], $response->toArray()); 587 | } 588 | 589 | public function testPostResource() 590 | { 591 | $client = $this->getHttpClient(__FUNCTION__); 592 | 593 | $h = fopen('php://temp', 'w+'); 594 | fwrite($h, 'foo=0123456789'); 595 | rewind($h); 596 | 597 | $response = $client->request('POST', 'http://localhost:8057/post', [ 598 | 'body' => $h, 599 | ]); 600 | 601 | $body = $response->toArray(); 602 | 603 | $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $body); 604 | } 605 | 606 | public function testPostCallback() 607 | { 608 | $client = $this->getHttpClient(__FUNCTION__); 609 | 610 | $response = $client->request('POST', 'http://localhost:8057/post', [ 611 | 'body' => function () { 612 | yield 'foo'; 613 | yield ''; 614 | yield '='; 615 | yield '0123456789'; 616 | }, 617 | ]); 618 | 619 | $this->assertSame(['foo' => '0123456789', 'REQUEST_METHOD' => 'POST'], $response->toArray()); 620 | } 621 | 622 | public function testCancel() 623 | { 624 | $client = $this->getHttpClient(__FUNCTION__); 625 | $response = $client->request('GET', 'http://localhost:8057/timeout-header'); 626 | 627 | $response->cancel(); 628 | $this->expectException(TransportExceptionInterface::class); 629 | $response->getHeaders(); 630 | } 631 | 632 | public function testInfoOnCanceledResponse() 633 | { 634 | $client = $this->getHttpClient(__FUNCTION__); 635 | 636 | $response = $client->request('GET', 'http://localhost:8057/timeout-header'); 637 | 638 | $this->assertFalse($response->getInfo('canceled')); 639 | $response->cancel(); 640 | $this->assertTrue($response->getInfo('canceled')); 641 | } 642 | 643 | public function testCancelInStream() 644 | { 645 | $client = $this->getHttpClient(__FUNCTION__); 646 | $response = $client->request('GET', 'http://localhost:8057/404'); 647 | 648 | foreach ($client->stream($response) as $chunk) { 649 | $response->cancel(); 650 | } 651 | 652 | $this->expectException(TransportExceptionInterface::class); 653 | 654 | foreach ($client->stream($response) as $chunk) { 655 | } 656 | } 657 | 658 | public function testOnProgressCancel() 659 | { 660 | $client = $this->getHttpClient(__FUNCTION__); 661 | $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ 662 | 'on_progress' => function ($dlNow) { 663 | if (0 < $dlNow) { 664 | throw new \Exception('Aborting the request.'); 665 | } 666 | }, 667 | ]); 668 | 669 | try { 670 | foreach ($client->stream([$response]) as $chunk) { 671 | } 672 | $this->fail(ClientExceptionInterface::class.' expected'); 673 | } catch (TransportExceptionInterface $e) { 674 | $this->assertSame('Aborting the request.', $e->getPrevious()->getMessage()); 675 | } 676 | 677 | $this->assertNotNull($response->getInfo('error')); 678 | $this->expectException(TransportExceptionInterface::class); 679 | $response->getContent(); 680 | } 681 | 682 | public function testOnProgressError() 683 | { 684 | $client = $this->getHttpClient(__FUNCTION__); 685 | $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ 686 | 'on_progress' => function ($dlNow) { 687 | if (0 < $dlNow) { 688 | throw new \Error('BUG.'); 689 | } 690 | }, 691 | ]); 692 | 693 | try { 694 | foreach ($client->stream([$response]) as $chunk) { 695 | } 696 | $this->fail('Error expected'); 697 | } catch (\Error $e) { 698 | $this->assertSame('BUG.', $e->getMessage()); 699 | } 700 | 701 | $this->assertNotNull($response->getInfo('error')); 702 | $this->expectException(TransportExceptionInterface::class); 703 | $response->getContent(); 704 | } 705 | 706 | public function testResolve() 707 | { 708 | $client = $this->getHttpClient(__FUNCTION__); 709 | $response = $client->request('GET', 'http://symfony.com:8057/', [ 710 | 'resolve' => ['symfony.com' => '127.0.0.1'], 711 | ]); 712 | 713 | $this->assertSame(200, $response->getStatusCode()); 714 | $this->assertSame(200, $client->request('GET', 'http://symfony.com:8057/')->getStatusCode()); 715 | 716 | $response = null; 717 | $this->expectException(TransportExceptionInterface::class); 718 | $client->request('GET', 'http://symfony.com:8057/', ['timeout' => 1]); 719 | } 720 | 721 | public function testIdnResolve() 722 | { 723 | $client = $this->getHttpClient(__FUNCTION__); 724 | 725 | $response = $client->request('GET', 'http://0-------------------------------------------------------------0.com:8057/', [ 726 | 'resolve' => ['0-------------------------------------------------------------0.com' => '127.0.0.1'], 727 | ]); 728 | 729 | $this->assertSame(200, $response->getStatusCode()); 730 | 731 | $response = $client->request('GET', 'http://Bücher.example:8057/', [ 732 | 'resolve' => ['xn--bcher-kva.example' => '127.0.0.1'], 733 | ]); 734 | 735 | $this->assertSame(200, $response->getStatusCode()); 736 | } 737 | 738 | public function testIPv6Resolve() 739 | { 740 | TestHttpServer::start(-8087); 741 | 742 | $client = $this->getHttpClient(__FUNCTION__); 743 | $response = $client->request('GET', 'http://symfony.com:8087/', [ 744 | 'resolve' => ['symfony.com' => '::1'], 745 | ]); 746 | 747 | $this->assertSame(200, $response->getStatusCode()); 748 | } 749 | 750 | public function testNotATimeout() 751 | { 752 | $client = $this->getHttpClient(__FUNCTION__); 753 | $response = $client->request('GET', 'http://localhost:8057/timeout-header', [ 754 | 'timeout' => 0.9, 755 | ]); 756 | sleep(1); 757 | $this->assertSame(200, $response->getStatusCode()); 758 | } 759 | 760 | public function testTimeoutOnAccess() 761 | { 762 | $client = $this->getHttpClient(__FUNCTION__); 763 | $response = $client->request('GET', 'http://localhost:8057/timeout-header', [ 764 | 'timeout' => 0.1, 765 | ]); 766 | 767 | $this->expectException(TransportExceptionInterface::class); 768 | $response->getHeaders(); 769 | } 770 | 771 | public function testTimeoutIsNotAFatalError() 772 | { 773 | usleep(300000); // wait for the previous test to release the server 774 | $client = $this->getHttpClient(__FUNCTION__); 775 | $response = $client->request('GET', 'http://localhost:8057/timeout-body', [ 776 | 'timeout' => 0.25, 777 | ]); 778 | 779 | try { 780 | $response->getContent(); 781 | $this->fail(TimeoutExceptionInterface::class.' expected'); 782 | } catch (TimeoutExceptionInterface $e) { 783 | } 784 | 785 | for ($i = 0; $i < 10; ++$i) { 786 | try { 787 | $this->assertSame('<1><2>', $response->getContent()); 788 | break; 789 | } catch (TimeoutExceptionInterface $e) { 790 | } 791 | } 792 | 793 | if (10 === $i) { 794 | throw $e; 795 | } 796 | } 797 | 798 | public function testTimeoutOnStream() 799 | { 800 | $client = $this->getHttpClient(__FUNCTION__); 801 | $response = $client->request('GET', 'http://localhost:8057/timeout-body'); 802 | 803 | $this->assertSame(200, $response->getStatusCode()); 804 | $chunks = $client->stream([$response], 0.2); 805 | 806 | $result = []; 807 | 808 | foreach ($chunks as $r => $chunk) { 809 | if ($chunk->isTimeout()) { 810 | $result[] = 't'; 811 | } else { 812 | $result[] = $chunk->getContent(); 813 | } 814 | } 815 | 816 | $this->assertSame(['<1>', 't'], $result); 817 | 818 | $chunks = $client->stream([$response]); 819 | 820 | foreach ($chunks as $r => $chunk) { 821 | $this->assertSame('<2>', $chunk->getContent()); 822 | $this->assertSame('<1><2>', $r->getContent()); 823 | 824 | return; 825 | } 826 | 827 | $this->fail('The response should have completed'); 828 | } 829 | 830 | public function testUncheckedTimeoutThrows() 831 | { 832 | $client = $this->getHttpClient(__FUNCTION__); 833 | $response = $client->request('GET', 'http://localhost:8057/timeout-body'); 834 | $chunks = $client->stream([$response], 0.1); 835 | 836 | $this->expectException(TransportExceptionInterface::class); 837 | 838 | foreach ($chunks as $r => $chunk) { 839 | } 840 | } 841 | 842 | public function testTimeoutWithActiveConcurrentStream() 843 | { 844 | $p1 = TestHttpServer::start(8067); 845 | $p2 = TestHttpServer::start(8077); 846 | 847 | $client = $this->getHttpClient(__FUNCTION__); 848 | $streamingResponse = $client->request('GET', 'http://localhost:8067/max-duration'); 849 | $blockingResponse = $client->request('GET', 'http://localhost:8077/timeout-body', [ 850 | 'timeout' => 0.25, 851 | ]); 852 | 853 | $this->assertSame(200, $streamingResponse->getStatusCode()); 854 | $this->assertSame(200, $blockingResponse->getStatusCode()); 855 | 856 | $this->expectException(TransportExceptionInterface::class); 857 | 858 | try { 859 | $blockingResponse->getContent(); 860 | } finally { 861 | $p1->stop(); 862 | $p2->stop(); 863 | } 864 | } 865 | 866 | public function testTimeoutOnInitialize() 867 | { 868 | $p1 = TestHttpServer::start(8067); 869 | $p2 = TestHttpServer::start(8077); 870 | 871 | $client = $this->getHttpClient(__FUNCTION__); 872 | $start = microtime(true); 873 | $responses = []; 874 | 875 | $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); 876 | $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); 877 | $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); 878 | $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); 879 | 880 | try { 881 | foreach ($responses as $response) { 882 | try { 883 | $response->getContent(); 884 | $this->fail(TransportExceptionInterface::class.' expected'); 885 | } catch (TransportExceptionInterface) { 886 | } 887 | } 888 | $responses = []; 889 | 890 | $duration = microtime(true) - $start; 891 | 892 | $this->assertLessThan(1.0, $duration); 893 | } finally { 894 | $p1->stop(); 895 | $p2->stop(); 896 | } 897 | } 898 | 899 | public function testTimeoutOnDestruct() 900 | { 901 | $p1 = TestHttpServer::start(8067); 902 | $p2 = TestHttpServer::start(8077); 903 | 904 | $client = $this->getHttpClient(__FUNCTION__); 905 | $start = microtime(true); 906 | $responses = []; 907 | 908 | $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); 909 | $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); 910 | $responses[] = $client->request('GET', 'http://localhost:8067/timeout-header', ['timeout' => 0.25]); 911 | $responses[] = $client->request('GET', 'http://localhost:8077/timeout-header', ['timeout' => 0.25]); 912 | 913 | try { 914 | while ($response = array_shift($responses)) { 915 | try { 916 | unset($response); 917 | $this->fail(TransportExceptionInterface::class.' expected'); 918 | } catch (TransportExceptionInterface) { 919 | } 920 | } 921 | 922 | $duration = microtime(true) - $start; 923 | 924 | $this->assertLessThan(1.0, $duration); 925 | } finally { 926 | $p1->stop(); 927 | $p2->stop(); 928 | } 929 | } 930 | 931 | public function testDestruct() 932 | { 933 | $client = $this->getHttpClient(__FUNCTION__); 934 | 935 | $start = microtime(true); 936 | $client->request('GET', 'http://localhost:8057/timeout-long'); 937 | $client = null; 938 | $duration = microtime(true) - $start; 939 | 940 | $this->assertGreaterThan(1, $duration); 941 | $this->assertLessThan(4, $duration); 942 | } 943 | 944 | public function testGetContentAfterDestruct() 945 | { 946 | $client = $this->getHttpClient(__FUNCTION__); 947 | 948 | try { 949 | $client->request('GET', 'http://localhost:8057/404'); 950 | $this->fail(ClientExceptionInterface::class.' expected'); 951 | } catch (ClientExceptionInterface $e) { 952 | $this->assertSame('GET', $e->getResponse()->toArray(false)['REQUEST_METHOD']); 953 | } 954 | } 955 | 956 | public function testGetEncodedContentAfterDestruct() 957 | { 958 | $client = $this->getHttpClient(__FUNCTION__); 959 | 960 | try { 961 | $client->request('GET', 'http://localhost:8057/404-gzipped'); 962 | $this->fail(ClientExceptionInterface::class.' expected'); 963 | } catch (ClientExceptionInterface $e) { 964 | $this->assertSame('some text', $e->getResponse()->getContent(false)); 965 | } 966 | } 967 | 968 | public function testProxy() 969 | { 970 | $client = $this->getHttpClient(__FUNCTION__); 971 | $response = $client->request('GET', 'http://localhost:8057/', [ 972 | 'proxy' => 'http://localhost:8057', 973 | ]); 974 | 975 | $body = $response->toArray(); 976 | $this->assertSame('localhost:8057', $body['HTTP_HOST']); 977 | $this->assertMatchesRegularExpression('#^http://(localhost|127\.0\.0\.1):8057/$#', $body['REQUEST_URI']); 978 | 979 | $response = $client->request('GET', 'http://localhost:8057/', [ 980 | 'proxy' => 'http://foo:b%3Dar@localhost:8057', 981 | ]); 982 | 983 | $body = $response->toArray(); 984 | $this->assertSame('Basic Zm9vOmI9YXI=', $body['HTTP_PROXY_AUTHORIZATION']); 985 | 986 | $_SERVER['http_proxy'] = 'http://localhost:8057'; 987 | try { 988 | $response = $client->request('GET', 'http://localhost:8057/'); 989 | $body = $response->toArray(); 990 | $this->assertSame('localhost:8057', $body['HTTP_HOST']); 991 | $this->assertMatchesRegularExpression('#^http://(localhost|127\.0\.0\.1):8057/$#', $body['REQUEST_URI']); 992 | } finally { 993 | unset($_SERVER['http_proxy']); 994 | } 995 | 996 | $response = $client->request('GET', 'http://localhost:8057/301/proxy', [ 997 | 'proxy' => 'http://localhost:8057', 998 | ]); 999 | 1000 | $body = $response->toArray(); 1001 | $this->assertSame('localhost:8057', $body['HTTP_HOST']); 1002 | $this->assertMatchesRegularExpression('#^http://(localhost|127\.0\.0\.1):8057/$#', $body['REQUEST_URI']); 1003 | } 1004 | 1005 | public function testNoProxy() 1006 | { 1007 | putenv('no_proxy='.$_SERVER['no_proxy'] = 'example.com, localhost'); 1008 | 1009 | try { 1010 | $client = $this->getHttpClient(__FUNCTION__); 1011 | $response = $client->request('GET', 'http://localhost:8057/', [ 1012 | 'proxy' => 'http://localhost:8057', 1013 | ]); 1014 | 1015 | $body = $response->toArray(); 1016 | 1017 | $this->assertSame('HTTP/1.1', $body['SERVER_PROTOCOL']); 1018 | $this->assertSame('/', $body['REQUEST_URI']); 1019 | $this->assertSame('GET', $body['REQUEST_METHOD']); 1020 | } finally { 1021 | putenv('no_proxy'); 1022 | unset($_SERVER['no_proxy']); 1023 | } 1024 | } 1025 | 1026 | /** 1027 | * @requires extension zlib 1028 | */ 1029 | #[RequiresPhpExtension('zlib')] 1030 | public function testAutoEncodingRequest() 1031 | { 1032 | $client = $this->getHttpClient(__FUNCTION__); 1033 | $response = $client->request('GET', 'http://localhost:8057'); 1034 | 1035 | $this->assertSame(200, $response->getStatusCode()); 1036 | 1037 | $headers = $response->getHeaders(); 1038 | 1039 | $this->assertSame(['Accept-Encoding'], $headers['vary']); 1040 | $this->assertStringContainsString('gzip', $headers['content-encoding'][0]); 1041 | 1042 | $body = $response->toArray(); 1043 | 1044 | $this->assertStringContainsString('gzip', $body['HTTP_ACCEPT_ENCODING']); 1045 | } 1046 | 1047 | public function testBaseUri() 1048 | { 1049 | $client = $this->getHttpClient(__FUNCTION__); 1050 | $response = $client->request('GET', '../404', [ 1051 | 'base_uri' => 'http://localhost:8057/abc/', 1052 | ]); 1053 | 1054 | $this->assertSame(404, $response->getStatusCode()); 1055 | $this->assertSame(['application/json'], $response->getHeaders(false)['content-type']); 1056 | } 1057 | 1058 | public function testQuery() 1059 | { 1060 | $client = $this->getHttpClient(__FUNCTION__); 1061 | $response = $client->request('GET', 'http://localhost:8057/?a=a', [ 1062 | 'query' => ['b' => 'b'], 1063 | ]); 1064 | 1065 | $body = $response->toArray(); 1066 | $this->assertSame('GET', $body['REQUEST_METHOD']); 1067 | $this->assertSame('/?a=a&b=b', $body['REQUEST_URI']); 1068 | } 1069 | 1070 | public function testInformationalResponse() 1071 | { 1072 | $client = $this->getHttpClient(__FUNCTION__); 1073 | $response = $client->request('GET', 'http://localhost:8057/103'); 1074 | 1075 | $this->assertSame('Here the body', $response->getContent()); 1076 | $this->assertSame(200, $response->getStatusCode()); 1077 | } 1078 | 1079 | public function testInformationalResponseStream() 1080 | { 1081 | $client = $this->getHttpClient(__FUNCTION__); 1082 | $response = $client->request('GET', 'http://localhost:8057/103'); 1083 | 1084 | $chunks = []; 1085 | foreach ($client->stream($response) as $chunk) { 1086 | $chunks[] = $chunk; 1087 | } 1088 | 1089 | $this->assertSame(103, $chunks[0]->getInformationalStatus()[0]); 1090 | $this->assertSame(['; rel=preload; as=style', '; rel=preload; as=script'], $chunks[0]->getInformationalStatus()[1]['link']); 1091 | $this->assertTrue($chunks[1]->isFirst()); 1092 | $this->assertSame('Here the body', $chunks[2]->getContent()); 1093 | $this->assertTrue($chunks[3]->isLast()); 1094 | $this->assertNull($chunks[3]->getInformationalStatus()); 1095 | 1096 | $this->assertSame(['date', 'content-length'], array_keys($response->getHeaders())); 1097 | $this->assertContains('Link: ; rel=preload; as=style', $response->getInfo('response_headers')); 1098 | } 1099 | 1100 | /** 1101 | * @requires extension zlib 1102 | */ 1103 | #[RequiresPhpExtension('zlib')] 1104 | public function testUserlandEncodingRequest() 1105 | { 1106 | $client = $this->getHttpClient(__FUNCTION__); 1107 | $response = $client->request('GET', 'http://localhost:8057', [ 1108 | 'headers' => ['Accept-Encoding' => 'gzip'], 1109 | ]); 1110 | 1111 | $headers = $response->getHeaders(); 1112 | 1113 | $this->assertSame(['Accept-Encoding'], $headers['vary']); 1114 | $this->assertStringContainsString('gzip', $headers['content-encoding'][0]); 1115 | 1116 | $body = $response->getContent(); 1117 | $this->assertSame("\x1F", $body[0]); 1118 | 1119 | $body = json_decode(gzdecode($body), true); 1120 | $this->assertSame('gzip', $body['HTTP_ACCEPT_ENCODING']); 1121 | } 1122 | 1123 | /** 1124 | * @requires extension zlib 1125 | */ 1126 | #[RequiresPhpExtension('zlib')] 1127 | public function testGzipBroken() 1128 | { 1129 | $client = $this->getHttpClient(__FUNCTION__); 1130 | $response = $client->request('GET', 'http://localhost:8057/gzip-broken'); 1131 | 1132 | $this->expectException(TransportExceptionInterface::class); 1133 | $response->getContent(); 1134 | } 1135 | 1136 | public function testMaxDuration() 1137 | { 1138 | $client = $this->getHttpClient(__FUNCTION__); 1139 | $response = $client->request('GET', 'http://localhost:8057/max-duration', [ 1140 | 'max_duration' => 0.1, 1141 | ]); 1142 | 1143 | $start = microtime(true); 1144 | 1145 | try { 1146 | $response->getContent(); 1147 | } catch (TransportExceptionInterface) { 1148 | $this->addToAssertionCount(1); 1149 | } 1150 | 1151 | $duration = microtime(true) - $start; 1152 | 1153 | $this->assertLessThan(10, $duration); 1154 | } 1155 | 1156 | public function testWithOptions() 1157 | { 1158 | $client = $this->getHttpClient(__FUNCTION__); 1159 | $client2 = $client->withOptions(['base_uri' => 'http://localhost:8057/']); 1160 | 1161 | $this->assertNotSame($client, $client2); 1162 | $this->assertSame($client::class, $client2::class); 1163 | 1164 | $response = $client2->request('GET', '/'); 1165 | $this->assertSame(200, $response->getStatusCode()); 1166 | } 1167 | 1168 | public function testBindToPort() 1169 | { 1170 | $client = $this->getHttpClient(__FUNCTION__); 1171 | $response = $client->request('GET', 'http://localhost:8057', ['bindto' => '127.0.0.1:9876']); 1172 | $response->getStatusCode(); 1173 | 1174 | $vars = $response->toArray(); 1175 | 1176 | self::assertSame('127.0.0.1', $vars['REMOTE_ADDR']); 1177 | self::assertSame('9876', $vars['REMOTE_PORT']); 1178 | } 1179 | 1180 | public function testBindToPortV6() 1181 | { 1182 | TestHttpServer::start(-8087); 1183 | 1184 | $client = $this->getHttpClient(__FUNCTION__); 1185 | $response = $client->request('GET', 'http://[::1]:8087', ['bindto' => '[::1]:9876']); 1186 | $response->getStatusCode(); 1187 | 1188 | $vars = $response->toArray(); 1189 | 1190 | self::assertSame('::1', $vars['REMOTE_ADDR']); 1191 | 1192 | if ('\\' !== \DIRECTORY_SEPARATOR) { 1193 | self::assertSame('9876', $vars['REMOTE_PORT']); 1194 | } 1195 | } 1196 | } 1197 | -------------------------------------------------------------------------------- /HttpClient/Test/TestHttpServer.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\HttpClient\Test; 13 | 14 | use Symfony\Component\Process\PhpExecutableFinder; 15 | use Symfony\Component\Process\Process; 16 | 17 | class TestHttpServer 18 | { 19 | private static array $process = []; 20 | 21 | /** 22 | * @param string|null $workingDirectory 23 | */ 24 | public static function start(int $port = 8057/* , ?string $workingDirectory = null */): Process 25 | { 26 | $workingDirectory = \func_get_args()[1] ?? __DIR__.'/Fixtures/web'; 27 | 28 | if (0 > $port) { 29 | $port = -$port; 30 | $ip = '[::1]'; 31 | } else { 32 | $ip = '127.0.0.1'; 33 | } 34 | 35 | if (isset(self::$process[$port])) { 36 | self::$process[$port]->stop(); 37 | } else { 38 | register_shutdown_function(static function () use ($port) { 39 | self::$process[$port]->stop(); 40 | }); 41 | } 42 | 43 | $finder = new PhpExecutableFinder(); 44 | $process = new Process(array_merge([$finder->find(false)], $finder->findArguments(), ['-dopcache.enable=0', '-dvariables_order=EGPCS', '-S', $ip.':'.$port])); 45 | $process->setWorkingDirectory($workingDirectory); 46 | $process->start(); 47 | self::$process[$port] = $process; 48 | 49 | do { 50 | usleep(50000); 51 | } while (!@fopen('http://'.$ip.':'.$port, 'r')); 52 | 53 | return $process; 54 | } 55 | 56 | public static function stop(int $port = 8057) 57 | { 58 | if (isset(self::$process[$port])) { 59 | self::$process[$port]->stop(); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /HttpClient/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/http-client-contracts", 3 | "type": "library", 4 | "description": "Generic abstractions related to HTTP clients", 5 | "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Nicolas Grekas", 11 | "email": "p@tchwork.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1" 20 | }, 21 | "autoload": { 22 | "psr-4": { "Symfony\\Contracts\\HttpClient\\": "" }, 23 | "exclude-from-classmap": [ 24 | "/Test/" 25 | ] 26 | }, 27 | "minimum-stability": "dev", 28 | "extra": { 29 | "branch-alias": { 30 | "dev-main": "3.6-dev" 31 | }, 32 | "thanks": { 33 | "name": "symfony/contracts", 34 | "url": "https://github.com/symfony/contracts" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Symfony Contracts 2 | ================= 3 | 4 | A set of abstractions extracted out of the Symfony components. 5 | 6 | Can be used to build on semantics that the Symfony components proved useful - and 7 | that already have battle tested implementations. 8 | 9 | Design Principles 10 | ----------------- 11 | 12 | * contracts are split by domain, each into their own sub-namespaces; 13 | * contracts are small and consistent sets of PHP interfaces, traits, normative 14 | docblocks and reference test suites when applicable; 15 | * all contracts must have a proven implementation to enter this repository; 16 | * they must be backward compatible with existing Symfony components. 17 | 18 | Packages that implement specific contracts should list them in the "provide" 19 | section of their "composer.json" file, using the `symfony/*-implementation` 20 | convention (e.g. `"provide": { "symfony/cache-implementation": "1.0" }`). 21 | 22 | FAQ 23 | --- 24 | 25 | ### How to use this package? 26 | 27 | The abstractions in this package are useful to achieve loose coupling and 28 | interoperability. By using the provided interfaces as type hints, you are able 29 | to reuse any implementations that match their contracts. It could be a Symfony 30 | component, or another one provided by the PHP community at large. 31 | 32 | Depending on their semantics, some interfaces can be combined with autowiring to 33 | seamlessly inject a service in your classes. 34 | 35 | Others might be useful as labeling interfaces, to hint about a specific behavior 36 | that could be enabled when using autoconfiguration or manual service tagging (or 37 | any other means provided by your framework.) 38 | 39 | ### How is this different from PHP-FIG's PSRs? 40 | 41 | When applicable, the provided contracts are built on top of PHP-FIG's PSRs. But 42 | the group has different goals and different processes. Here, we're focusing on 43 | providing abstractions that are useful on their own while still compatible with 44 | implementations provided by Symfony. Although not the main target, we hope that 45 | the declared contracts will directly or indirectly contribute to the PHP-FIG. 46 | 47 | Resources 48 | --------- 49 | 50 | * [Documentation](https://symfony.com/doc/current/components/contracts.html) 51 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 52 | * [Report issues](https://github.com/symfony/symfony/issues) and 53 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 54 | in the [main Symfony repository](https://github.com/symfony/symfony) 55 | -------------------------------------------------------------------------------- /Service/Attribute/Required.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service\Attribute; 13 | 14 | /** 15 | * A required dependency. 16 | * 17 | * This attribute indicates that a property holds a required dependency. The annotated property or method should be 18 | * considered during the instantiation process of the containing class. 19 | * 20 | * @author Alexander M. Turek 21 | */ 22 | #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] 23 | final class Required 24 | { 25 | } 26 | -------------------------------------------------------------------------------- /Service/Attribute/SubscribedService.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service\Attribute; 13 | 14 | use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait; 15 | use Symfony\Contracts\Service\ServiceSubscriberInterface; 16 | 17 | /** 18 | * For use as the return value for {@see ServiceSubscriberInterface}. 19 | * 20 | * @example new SubscribedService('http_client', HttpClientInterface::class, false, new Target('githubApi')) 21 | * 22 | * Use with {@see ServiceMethodsSubscriberTrait} to mark a method's return type 23 | * as a subscribed service. 24 | * 25 | * @author Kevin Bond 26 | */ 27 | #[\Attribute(\Attribute::TARGET_METHOD)] 28 | final class SubscribedService 29 | { 30 | /** @var object[] */ 31 | public array $attributes; 32 | 33 | /** 34 | * @param string|null $key The key to use for the service 35 | * @param class-string|null $type The service class 36 | * @param bool $nullable Whether the service is optional 37 | * @param object|object[] $attributes One or more dependency injection attributes to use 38 | */ 39 | public function __construct( 40 | public ?string $key = null, 41 | public ?string $type = null, 42 | public bool $nullable = false, 43 | array|object $attributes = [], 44 | ) { 45 | $this->attributes = \is_array($attributes) ? $attributes : [$attributes]; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Service/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | The changelog is maintained for all Symfony contracts at the following URL: 5 | https://github.com/symfony/contracts/blob/main/CHANGELOG.md 6 | -------------------------------------------------------------------------------- /Service/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Service/README.md: -------------------------------------------------------------------------------- 1 | Symfony Service Contracts 2 | ========================= 3 | 4 | A set of abstractions extracted out of the Symfony components. 5 | 6 | Can be used to build on semantics that the Symfony components proved useful and 7 | that already have battle tested implementations. 8 | 9 | See https://github.com/symfony/contracts/blob/main/README.md for more information. 10 | -------------------------------------------------------------------------------- /Service/ResetInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service; 13 | 14 | /** 15 | * Provides a way to reset an object to its initial state. 16 | * 17 | * When calling the "reset()" method on an object, it should be put back to its 18 | * initial state. This usually means clearing any internal buffers and forwarding 19 | * the call to internal dependencies. All properties of the object should be put 20 | * back to the same state it had when it was first ready to use. 21 | * 22 | * This method could be called, for example, to recycle objects that are used as 23 | * services, so that they can be used to handle several requests in the same 24 | * process loop (note that we advise making your services stateless instead of 25 | * implementing this interface when possible.) 26 | */ 27 | interface ResetInterface 28 | { 29 | /** 30 | * @return void 31 | */ 32 | public function reset(); 33 | } 34 | -------------------------------------------------------------------------------- /Service/ServiceCollectionInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service; 13 | 14 | /** 15 | * A ServiceProviderInterface that is also countable and iterable. 16 | * 17 | * @author Kevin Bond 18 | * 19 | * @template-covariant T of mixed 20 | * 21 | * @extends ServiceProviderInterface 22 | * @extends \IteratorAggregate 23 | */ 24 | interface ServiceCollectionInterface extends ServiceProviderInterface, \Countable, \IteratorAggregate 25 | { 26 | } 27 | -------------------------------------------------------------------------------- /Service/ServiceLocatorTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service; 13 | 14 | use Psr\Container\ContainerExceptionInterface; 15 | use Psr\Container\NotFoundExceptionInterface; 16 | 17 | // Help opcache.preload discover always-needed symbols 18 | class_exists(ContainerExceptionInterface::class); 19 | class_exists(NotFoundExceptionInterface::class); 20 | 21 | /** 22 | * A trait to help implement ServiceProviderInterface. 23 | * 24 | * @author Robin Chalas 25 | * @author Nicolas Grekas 26 | */ 27 | trait ServiceLocatorTrait 28 | { 29 | private array $loading = []; 30 | private array $providedTypes; 31 | 32 | /** 33 | * @param array $factories 34 | */ 35 | public function __construct( 36 | private array $factories, 37 | ) { 38 | } 39 | 40 | public function has(string $id): bool 41 | { 42 | return isset($this->factories[$id]); 43 | } 44 | 45 | public function get(string $id): mixed 46 | { 47 | if (!isset($this->factories[$id])) { 48 | throw $this->createNotFoundException($id); 49 | } 50 | 51 | if (isset($this->loading[$id])) { 52 | $ids = array_values($this->loading); 53 | $ids = \array_slice($this->loading, array_search($id, $ids)); 54 | $ids[] = $id; 55 | 56 | throw $this->createCircularReferenceException($id, $ids); 57 | } 58 | 59 | $this->loading[$id] = $id; 60 | try { 61 | return $this->factories[$id]($this); 62 | } finally { 63 | unset($this->loading[$id]); 64 | } 65 | } 66 | 67 | public function getProvidedServices(): array 68 | { 69 | if (!isset($this->providedTypes)) { 70 | $this->providedTypes = []; 71 | 72 | foreach ($this->factories as $name => $factory) { 73 | if (!\is_callable($factory)) { 74 | $this->providedTypes[$name] = '?'; 75 | } else { 76 | $type = (new \ReflectionFunction($factory))->getReturnType(); 77 | 78 | $this->providedTypes[$name] = $type ? ($type->allowsNull() ? '?' : '').($type instanceof \ReflectionNamedType ? $type->getName() : $type) : '?'; 79 | } 80 | } 81 | } 82 | 83 | return $this->providedTypes; 84 | } 85 | 86 | private function createNotFoundException(string $id): NotFoundExceptionInterface 87 | { 88 | if (!$alternatives = array_keys($this->factories)) { 89 | $message = 'is empty...'; 90 | } else { 91 | $last = array_pop($alternatives); 92 | if ($alternatives) { 93 | $message = \sprintf('only knows about the "%s" and "%s" services.', implode('", "', $alternatives), $last); 94 | } else { 95 | $message = \sprintf('only knows about the "%s" service.', $last); 96 | } 97 | } 98 | 99 | if ($this->loading) { 100 | $message = \sprintf('The service "%s" has a dependency on a non-existent service "%s". This locator %s', end($this->loading), $id, $message); 101 | } else { 102 | $message = \sprintf('Service "%s" not found: the current service locator %s', $id, $message); 103 | } 104 | 105 | return new class($message) extends \InvalidArgumentException implements NotFoundExceptionInterface { 106 | }; 107 | } 108 | 109 | private function createCircularReferenceException(string $id, array $path): ContainerExceptionInterface 110 | { 111 | return new class(\sprintf('Circular reference detected for service "%s", path: "%s".', $id, implode(' -> ', $path))) extends \RuntimeException implements ContainerExceptionInterface { 112 | }; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Service/ServiceMethodsSubscriberTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service; 13 | 14 | use Psr\Container\ContainerInterface; 15 | use Symfony\Contracts\Service\Attribute\Required; 16 | use Symfony\Contracts\Service\Attribute\SubscribedService; 17 | 18 | /** 19 | * Implementation of ServiceSubscriberInterface that determines subscribed services 20 | * from methods that have the #[SubscribedService] attribute. 21 | * 22 | * Service ids are available as "ClassName::methodName" so that the implementation 23 | * of subscriber methods can be just `return $this->container->get(__METHOD__);`. 24 | * 25 | * @author Kevin Bond 26 | */ 27 | trait ServiceMethodsSubscriberTrait 28 | { 29 | protected ContainerInterface $container; 30 | 31 | public static function getSubscribedServices(): array 32 | { 33 | $services = method_exists(get_parent_class(self::class) ?: '', __FUNCTION__) ? parent::getSubscribedServices() : []; 34 | 35 | foreach ((new \ReflectionClass(self::class))->getMethods() as $method) { 36 | if (self::class !== $method->getDeclaringClass()->name) { 37 | continue; 38 | } 39 | 40 | if (!$attribute = $method->getAttributes(SubscribedService::class)[0] ?? null) { 41 | continue; 42 | } 43 | 44 | if ($method->isStatic() || $method->isAbstract() || $method->isGenerator() || $method->isInternal() || $method->getNumberOfRequiredParameters()) { 45 | throw new \LogicException(\sprintf('Cannot use "%s" on method "%s::%s()" (can only be used on non-static, non-abstract methods with no parameters).', SubscribedService::class, self::class, $method->name)); 46 | } 47 | 48 | if (!$returnType = $method->getReturnType()) { 49 | throw new \LogicException(\sprintf('Cannot use "%s" on methods without a return type in "%s::%s()".', SubscribedService::class, $method->name, self::class)); 50 | } 51 | 52 | /* @var SubscribedService $attribute */ 53 | $attribute = $attribute->newInstance(); 54 | $attribute->key ??= self::class.'::'.$method->name; 55 | $attribute->type ??= $returnType instanceof \ReflectionNamedType ? $returnType->getName() : (string) $returnType; 56 | $attribute->nullable = $attribute->nullable ?: $returnType->allowsNull(); 57 | 58 | if ($attribute->attributes) { 59 | $services[] = $attribute; 60 | } else { 61 | $services[$attribute->key] = ($attribute->nullable ? '?' : '').$attribute->type; 62 | } 63 | } 64 | 65 | return $services; 66 | } 67 | 68 | #[Required] 69 | public function setContainer(ContainerInterface $container): ?ContainerInterface 70 | { 71 | $ret = null; 72 | if (method_exists(get_parent_class(self::class) ?: '', __FUNCTION__)) { 73 | $ret = parent::setContainer($container); 74 | } 75 | 76 | $this->container = $container; 77 | 78 | return $ret; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Service/ServiceProviderInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service; 13 | 14 | use Psr\Container\ContainerInterface; 15 | 16 | /** 17 | * A ServiceProviderInterface exposes the identifiers and the types of services provided by a container. 18 | * 19 | * @author Nicolas Grekas 20 | * @author Mateusz Sip 21 | * 22 | * @template-covariant T of mixed 23 | */ 24 | interface ServiceProviderInterface extends ContainerInterface 25 | { 26 | /** 27 | * @return T 28 | */ 29 | public function get(string $id): mixed; 30 | 31 | public function has(string $id): bool; 32 | 33 | /** 34 | * Returns an associative array of service types keyed by the identifiers provided by the current container. 35 | * 36 | * Examples: 37 | * 38 | * * ['logger' => 'Psr\Log\LoggerInterface'] means the object provides a service named "logger" that implements Psr\Log\LoggerInterface 39 | * * ['foo' => '?'] means the container provides service name "foo" of unspecified type 40 | * * ['bar' => '?Bar\Baz'] means the container provides a service "bar" of type Bar\Baz|null 41 | * 42 | * @return array The provided service types, keyed by service names 43 | */ 44 | public function getProvidedServices(): array; 45 | } 46 | -------------------------------------------------------------------------------- /Service/ServiceSubscriberInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service; 13 | 14 | use Symfony\Contracts\Service\Attribute\SubscribedService; 15 | 16 | /** 17 | * A ServiceSubscriber exposes its dependencies via the static {@link getSubscribedServices} method. 18 | * 19 | * The getSubscribedServices method returns an array of service types required by such instances, 20 | * optionally keyed by the service names used internally. Service types that start with an interrogation 21 | * mark "?" are optional, while the other ones are mandatory service dependencies. 22 | * 23 | * The injected service locators SHOULD NOT allow access to any other services not specified by the method. 24 | * 25 | * It is expected that ServiceSubscriber instances consume PSR-11-based service locators internally. 26 | * This interface does not dictate any injection method for these service locators, although constructor 27 | * injection is recommended. 28 | * 29 | * @author Nicolas Grekas 30 | */ 31 | interface ServiceSubscriberInterface 32 | { 33 | /** 34 | * Returns an array of service types (or {@see SubscribedService} objects) required 35 | * by such instances, optionally keyed by the service names used internally. 36 | * 37 | * For mandatory dependencies: 38 | * 39 | * * ['logger' => 'Psr\Log\LoggerInterface'] means the objects use the "logger" name 40 | * internally to fetch a service which must implement Psr\Log\LoggerInterface. 41 | * * ['loggers' => 'Psr\Log\LoggerInterface[]'] means the objects use the "loggers" name 42 | * internally to fetch an iterable of Psr\Log\LoggerInterface instances. 43 | * * ['Psr\Log\LoggerInterface'] is a shortcut for 44 | * * ['Psr\Log\LoggerInterface' => 'Psr\Log\LoggerInterface'] 45 | * 46 | * otherwise: 47 | * 48 | * * ['logger' => '?Psr\Log\LoggerInterface'] denotes an optional dependency 49 | * * ['loggers' => '?Psr\Log\LoggerInterface[]'] denotes an optional iterable dependency 50 | * * ['?Psr\Log\LoggerInterface'] is a shortcut for 51 | * * ['Psr\Log\LoggerInterface' => '?Psr\Log\LoggerInterface'] 52 | * 53 | * additionally, an array of {@see SubscribedService}'s can be returned: 54 | * 55 | * * [new SubscribedService('logger', Psr\Log\LoggerInterface::class)] 56 | * * [new SubscribedService(type: Psr\Log\LoggerInterface::class, nullable: true)] 57 | * * [new SubscribedService('http_client', HttpClientInterface::class, attributes: new Target('githubApi'))] 58 | * 59 | * @return string[]|SubscribedService[] The required service types, optionally keyed by service names 60 | */ 61 | public static function getSubscribedServices(): array; 62 | } 63 | -------------------------------------------------------------------------------- /Service/ServiceSubscriberTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service; 13 | 14 | use Psr\Container\ContainerInterface; 15 | use Symfony\Contracts\Service\Attribute\Required; 16 | use Symfony\Contracts\Service\Attribute\SubscribedService; 17 | 18 | trigger_deprecation('symfony/contracts', 'v3.5', '"%s" is deprecated, use "ServiceMethodsSubscriberTrait" instead.', ServiceSubscriberTrait::class); 19 | 20 | /** 21 | * Implementation of ServiceSubscriberInterface that determines subscribed services 22 | * from methods that have the #[SubscribedService] attribute. 23 | * 24 | * Service ids are available as "ClassName::methodName" so that the implementation 25 | * of subscriber methods can be just `return $this->container->get(__METHOD__);`. 26 | * 27 | * @property ContainerInterface $container 28 | * 29 | * @author Kevin Bond 30 | * 31 | * @deprecated since symfony/contracts v3.5, use ServiceMethodsSubscriberTrait instead 32 | */ 33 | trait ServiceSubscriberTrait 34 | { 35 | public static function getSubscribedServices(): array 36 | { 37 | $services = method_exists(get_parent_class(self::class) ?: '', __FUNCTION__) ? parent::getSubscribedServices() : []; 38 | 39 | foreach ((new \ReflectionClass(self::class))->getMethods() as $method) { 40 | if (self::class !== $method->getDeclaringClass()->name) { 41 | continue; 42 | } 43 | 44 | if (!$attribute = $method->getAttributes(SubscribedService::class)[0] ?? null) { 45 | continue; 46 | } 47 | 48 | if ($method->isStatic() || $method->isAbstract() || $method->isGenerator() || $method->isInternal() || $method->getNumberOfRequiredParameters()) { 49 | throw new \LogicException(\sprintf('Cannot use "%s" on method "%s::%s()" (can only be used on non-static, non-abstract methods with no parameters).', SubscribedService::class, self::class, $method->name)); 50 | } 51 | 52 | if (!$returnType = $method->getReturnType()) { 53 | throw new \LogicException(\sprintf('Cannot use "%s" on methods without a return type in "%s::%s()".', SubscribedService::class, $method->name, self::class)); 54 | } 55 | 56 | /* @var SubscribedService $attribute */ 57 | $attribute = $attribute->newInstance(); 58 | $attribute->key ??= self::class.'::'.$method->name; 59 | $attribute->type ??= $returnType instanceof \ReflectionNamedType ? $returnType->getName() : (string) $returnType; 60 | $attribute->nullable = $attribute->nullable ?: $returnType->allowsNull(); 61 | 62 | if ($attribute->attributes) { 63 | $services[] = $attribute; 64 | } else { 65 | $services[$attribute->key] = ($attribute->nullable ? '?' : '').$attribute->type; 66 | } 67 | } 68 | 69 | return $services; 70 | } 71 | 72 | #[Required] 73 | public function setContainer(ContainerInterface $container): ?ContainerInterface 74 | { 75 | $ret = null; 76 | if (method_exists(get_parent_class(self::class) ?: '', __FUNCTION__)) { 77 | $ret = parent::setContainer($container); 78 | } 79 | 80 | $this->container = $container; 81 | 82 | return $ret; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Service/Test/ServiceLocatorTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service\Test; 13 | 14 | class_alias(ServiceLocatorTestCase::class, ServiceLocatorTest::class); 15 | 16 | if (false) { 17 | /** 18 | * @deprecated since PHPUnit 9.6 19 | */ 20 | class ServiceLocatorTest 21 | { 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Service/Test/ServiceLocatorTestCase.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Service\Test; 13 | 14 | use PHPUnit\Framework\TestCase; 15 | use Psr\Container\ContainerExceptionInterface; 16 | use Psr\Container\ContainerInterface; 17 | use Psr\Container\NotFoundExceptionInterface; 18 | use Symfony\Contracts\Service\ServiceLocatorTrait; 19 | 20 | abstract class ServiceLocatorTestCase extends TestCase 21 | { 22 | /** 23 | * @param array $factories 24 | */ 25 | protected function getServiceLocator(array $factories): ContainerInterface 26 | { 27 | return new class($factories) implements ContainerInterface { 28 | use ServiceLocatorTrait; 29 | }; 30 | } 31 | 32 | public function testHas() 33 | { 34 | $locator = $this->getServiceLocator([ 35 | 'foo' => fn () => 'bar', 36 | 'bar' => fn () => 'baz', 37 | fn () => 'dummy', 38 | ]); 39 | 40 | $this->assertTrue($locator->has('foo')); 41 | $this->assertTrue($locator->has('bar')); 42 | $this->assertFalse($locator->has('dummy')); 43 | } 44 | 45 | public function testGet() 46 | { 47 | $locator = $this->getServiceLocator([ 48 | 'foo' => fn () => 'bar', 49 | 'bar' => fn () => 'baz', 50 | ]); 51 | 52 | $this->assertSame('bar', $locator->get('foo')); 53 | $this->assertSame('baz', $locator->get('bar')); 54 | } 55 | 56 | public function testGetDoesNotMemoize() 57 | { 58 | $i = 0; 59 | $locator = $this->getServiceLocator([ 60 | 'foo' => function () use (&$i) { 61 | ++$i; 62 | 63 | return 'bar'; 64 | }, 65 | ]); 66 | 67 | $this->assertSame('bar', $locator->get('foo')); 68 | $this->assertSame('bar', $locator->get('foo')); 69 | $this->assertSame(2, $i); 70 | } 71 | 72 | public function testThrowsOnUndefinedInternalService() 73 | { 74 | $locator = $this->getServiceLocator([ 75 | 'foo' => function () use (&$locator) { return $locator->get('bar'); }, 76 | ]); 77 | 78 | $this->expectException(NotFoundExceptionInterface::class); 79 | $this->expectExceptionMessage('The service "foo" has a dependency on a non-existent service "bar". This locator only knows about the "foo" service.'); 80 | 81 | $locator->get('foo'); 82 | } 83 | 84 | public function testThrowsOnCircularReference() 85 | { 86 | $locator = $this->getServiceLocator([ 87 | 'foo' => function () use (&$locator) { return $locator->get('bar'); }, 88 | 'bar' => function () use (&$locator) { return $locator->get('baz'); }, 89 | 'baz' => function () use (&$locator) { return $locator->get('bar'); }, 90 | ]); 91 | 92 | $this->expectException(ContainerExceptionInterface::class); 93 | $this->expectExceptionMessage('Circular reference detected for service "bar", path: "bar -> baz -> bar".'); 94 | 95 | $locator->get('foo'); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Service/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/service-contracts", 3 | "type": "library", 4 | "description": "Generic abstractions related to writing services", 5 | "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Nicolas Grekas", 11 | "email": "p@tchwork.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1", 20 | "psr/container": "^1.1|^2.0", 21 | "symfony/deprecation-contracts": "^2.5|^3" 22 | }, 23 | "conflict": { 24 | "ext-psr": "<1.1|>=2" 25 | }, 26 | "autoload": { 27 | "psr-4": { "Symfony\\Contracts\\Service\\": "" }, 28 | "exclude-from-classmap": [ 29 | "/Test/" 30 | ] 31 | }, 32 | "minimum-stability": "dev", 33 | "extra": { 34 | "branch-alias": { 35 | "dev-main": "3.6-dev" 36 | }, 37 | "thanks": { 38 | "name": "symfony/contracts", 39 | "url": "https://github.com/symfony/contracts" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/Cache/CacheTraitTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Tests\Cache; 13 | 14 | use PHPUnit\Framework\TestCase; 15 | use Psr\Cache\CacheItemInterface; 16 | use Psr\Cache\CacheItemPoolInterface; 17 | use Symfony\Contracts\Cache\CacheTrait; 18 | 19 | /** 20 | * @author Tobias Nyholm 21 | */ 22 | class CacheTraitTest extends TestCase 23 | { 24 | public function testSave() 25 | { 26 | $item = $this->createMock(CacheItemInterface::class); 27 | $item->method('set') 28 | ->willReturn($item); 29 | $item->method('isHit') 30 | ->willReturn(false); 31 | 32 | $item->expects($this->once()) 33 | ->method('set') 34 | ->with('computed data'); 35 | 36 | $cache = $this->getMockBuilder(TestPool::class) 37 | ->onlyMethods(['getItem', 'save']) 38 | ->getMock(); 39 | $cache->expects($this->once()) 40 | ->method('getItem') 41 | ->with('key') 42 | ->willReturn($item); 43 | $cache->expects($this->once()) 44 | ->method('save'); 45 | 46 | $callback = fn (CacheItemInterface $item) => 'computed data'; 47 | 48 | $cache->get('key', $callback); 49 | } 50 | 51 | public function testNoCallbackCallOnHit() 52 | { 53 | $item = $this->createMock(CacheItemInterface::class); 54 | $item->method('isHit') 55 | ->willReturn(true); 56 | 57 | $item->expects($this->never()) 58 | ->method('set'); 59 | 60 | $cache = $this->getMockBuilder(TestPool::class) 61 | ->onlyMethods(['getItem', 'save']) 62 | ->getMock(); 63 | 64 | $cache->expects($this->once()) 65 | ->method('getItem') 66 | ->with('key') 67 | ->willReturn($item); 68 | $cache->expects($this->never()) 69 | ->method('save'); 70 | 71 | $callback = function (CacheItemInterface $item) { 72 | $this->fail('This code should never be reached'); 73 | }; 74 | 75 | $cache->get('key', $callback); 76 | } 77 | 78 | public function testRecomputeOnBetaInf() 79 | { 80 | $item = $this->createMock(CacheItemInterface::class); 81 | $item->method('set') 82 | ->willReturn($item); 83 | $item->method('isHit') 84 | // We want to recompute even if it is a hit 85 | ->willReturn(true); 86 | 87 | $item->expects($this->once()) 88 | ->method('set') 89 | ->with('computed data'); 90 | 91 | $cache = $this->getMockBuilder(TestPool::class) 92 | ->onlyMethods(['getItem', 'save']) 93 | ->getMock(); 94 | 95 | $cache->expects($this->once()) 96 | ->method('getItem') 97 | ->with('key') 98 | ->willReturn($item); 99 | $cache->expects($this->once()) 100 | ->method('save'); 101 | 102 | $callback = fn (CacheItemInterface $item) => 'computed data'; 103 | 104 | $cache->get('key', $callback, \INF); 105 | } 106 | 107 | public function testExceptionOnNegativeBeta() 108 | { 109 | $cache = $this->getMockBuilder(TestPool::class) 110 | ->onlyMethods(['getItem', 'save']) 111 | ->getMock(); 112 | 113 | $callback = fn (CacheItemInterface $item) => 'computed data'; 114 | 115 | $this->expectException(\InvalidArgumentException::class); 116 | $cache->get('key', $callback, -2); 117 | } 118 | } 119 | 120 | class TestPool implements CacheItemPoolInterface 121 | { 122 | use CacheTrait; 123 | 124 | public function hasItem($key): bool 125 | { 126 | } 127 | 128 | public function deleteItem($key): bool 129 | { 130 | } 131 | 132 | public function deleteItems(array $keys = []): bool 133 | { 134 | } 135 | 136 | public function getItem($key): CacheItemInterface 137 | { 138 | } 139 | 140 | public function getItems(array $key = []): iterable 141 | { 142 | } 143 | 144 | public function saveDeferred(CacheItemInterface $item): bool 145 | { 146 | } 147 | 148 | public function save(CacheItemInterface $item): bool 149 | { 150 | } 151 | 152 | public function commit(): bool 153 | { 154 | } 155 | 156 | public function clear(): bool 157 | { 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /Tests/Service/LegacyTestService.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Tests\Service; 13 | 14 | use Psr\Container\ContainerInterface; 15 | use Symfony\Contracts\Service\Attribute\Required; 16 | use Symfony\Contracts\Service\Attribute\SubscribedService; 17 | use Symfony\Contracts\Service\ServiceSubscriberInterface; 18 | use Symfony\Contracts\Service\ServiceSubscriberTrait; 19 | 20 | class LegacyParentTestService 21 | { 22 | public function aParentService(): Service1 23 | { 24 | } 25 | 26 | public function setContainer(ContainerInterface $container): ?ContainerInterface 27 | { 28 | return $container; 29 | } 30 | } 31 | 32 | class LegacyTestService extends LegacyParentTestService implements ServiceSubscriberInterface 33 | { 34 | use ServiceSubscriberTrait; 35 | 36 | protected $container; 37 | 38 | #[SubscribedService] 39 | public function aService(): Service2 40 | { 41 | return $this->container->get(__METHOD__); 42 | } 43 | 44 | #[SubscribedService(nullable: true)] 45 | public function nullableInAttribute(): Service2 46 | { 47 | if (!$this->container->has(__METHOD__)) { 48 | throw new \LogicException(); 49 | } 50 | 51 | return $this->container->get(__METHOD__); 52 | } 53 | 54 | #[SubscribedService] 55 | public function nullableReturnType(): ?Service2 56 | { 57 | return $this->container->get(__METHOD__); 58 | } 59 | 60 | #[SubscribedService(attributes: new Required())] 61 | public function withAttribute(): ?Service2 62 | { 63 | return $this->container->get(__METHOD__); 64 | } 65 | } 66 | 67 | class LegacyChildTestService extends LegacyTestService 68 | { 69 | #[SubscribedService] 70 | public function aChildService(): LegacyService3 71 | { 72 | return $this->container->get(__METHOD__); 73 | } 74 | } 75 | 76 | class LegacyParentWithMagicCall 77 | { 78 | public function __call($method, $args) 79 | { 80 | throw new \BadMethodCallException('Should not be called.'); 81 | } 82 | 83 | public static function __callStatic($method, $args) 84 | { 85 | throw new \BadMethodCallException('Should not be called.'); 86 | } 87 | } 88 | 89 | class LegacyService3 90 | { 91 | } 92 | 93 | class LegacyParentTestService2 94 | { 95 | /** @var ContainerInterface */ 96 | protected $container; 97 | 98 | public function setContainer(ContainerInterface $container) 99 | { 100 | $previous = $this->container ?? null; 101 | $this->container = $container; 102 | 103 | return $previous; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Tests/Service/ServiceMethodsSubscriberTraitTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Tests\Service; 13 | 14 | use PHPUnit\Framework\TestCase; 15 | use Psr\Container\ContainerInterface; 16 | use Symfony\Contracts\Service\Attribute\Required; 17 | use Symfony\Contracts\Service\Attribute\SubscribedService; 18 | use Symfony\Contracts\Service\ServiceLocatorTrait; 19 | use Symfony\Contracts\Service\ServiceMethodsSubscriberTrait; 20 | use Symfony\Contracts\Service\ServiceSubscriberInterface; 21 | 22 | class ServiceMethodsSubscriberTraitTest extends TestCase 23 | { 24 | public function testMethodsOnParentsAndChildrenAreIgnoredInGetSubscribedServices() 25 | { 26 | $expected = [ 27 | TestService::class.'::aService' => Service2::class, 28 | TestService::class.'::nullableInAttribute' => '?'.Service2::class, 29 | TestService::class.'::nullableReturnType' => '?'.Service2::class, 30 | new SubscribedService(TestService::class.'::withAttribute', Service2::class, true, new Required()), 31 | ]; 32 | 33 | $this->assertEquals($expected, ChildTestService::getSubscribedServices()); 34 | } 35 | 36 | public function testSetContainerIsCalledOnParent() 37 | { 38 | $container = new class([]) implements ContainerInterface { 39 | use ServiceLocatorTrait; 40 | }; 41 | 42 | $this->assertSame($container, (new TestService())->setContainer($container)); 43 | } 44 | 45 | public function testParentNotCalledIfHasMagicCall() 46 | { 47 | $container = new class([]) implements ContainerInterface { 48 | use ServiceLocatorTrait; 49 | }; 50 | $service = new class extends ParentWithMagicCall { 51 | use ServiceMethodsSubscriberTrait; 52 | }; 53 | 54 | $this->assertNull($service->setContainer($container)); 55 | $this->assertSame([], $service::getSubscribedServices()); 56 | } 57 | 58 | public function testParentNotCalledIfNoParent() 59 | { 60 | $container = new class([]) implements ContainerInterface { 61 | use ServiceLocatorTrait; 62 | }; 63 | $service = new class { 64 | use ServiceMethodsSubscriberTrait; 65 | }; 66 | 67 | $this->assertNull($service->setContainer($container)); 68 | $this->assertSame([], $service::getSubscribedServices()); 69 | } 70 | 71 | public function testSetContainerCalledFirstOnParent() 72 | { 73 | $container1 = new class([]) implements ContainerInterface { 74 | use ServiceLocatorTrait; 75 | }; 76 | $container2 = clone $container1; 77 | 78 | $testService = new TestService2(); 79 | $this->assertNull($testService->setContainer($container1)); 80 | $this->assertSame($container1, $testService->setContainer($container2)); 81 | } 82 | } 83 | 84 | class ParentTestService 85 | { 86 | public function aParentService(): Service1 87 | { 88 | } 89 | 90 | public function setContainer(ContainerInterface $container): ?ContainerInterface 91 | { 92 | return $container; 93 | } 94 | } 95 | 96 | class TestService extends ParentTestService implements ServiceSubscriberInterface 97 | { 98 | use ServiceMethodsSubscriberTrait; 99 | 100 | protected ContainerInterface $container; 101 | 102 | #[SubscribedService] 103 | public function aService(): Service2 104 | { 105 | return $this->container->get(__METHOD__); 106 | } 107 | 108 | #[SubscribedService(nullable: true)] 109 | public function nullableInAttribute(): Service2 110 | { 111 | if (!$this->container->has(__METHOD__)) { 112 | throw new \LogicException(); 113 | } 114 | 115 | return $this->container->get(__METHOD__); 116 | } 117 | 118 | #[SubscribedService] 119 | public function nullableReturnType(): ?Service2 120 | { 121 | return $this->container->get(__METHOD__); 122 | } 123 | 124 | #[SubscribedService(attributes: new Required())] 125 | public function withAttribute(): ?Service2 126 | { 127 | return $this->container->get(__METHOD__); 128 | } 129 | } 130 | 131 | class ChildTestService extends TestService 132 | { 133 | #[SubscribedService] 134 | public function aChildService(): Service3 135 | { 136 | return $this->container->get(__METHOD__); 137 | } 138 | } 139 | 140 | class ParentWithMagicCall 141 | { 142 | public function __call($method, $args) 143 | { 144 | throw new \BadMethodCallException('Should not be called.'); 145 | } 146 | 147 | public static function __callStatic($method, $args) 148 | { 149 | throw new \BadMethodCallException('Should not be called.'); 150 | } 151 | } 152 | 153 | class Service1 154 | { 155 | } 156 | 157 | class Service2 158 | { 159 | } 160 | 161 | class Service3 162 | { 163 | } 164 | 165 | class ParentTestService2 166 | { 167 | protected ContainerInterface $container; 168 | 169 | public function setContainer(ContainerInterface $container) 170 | { 171 | $previous = $this->container ?? null; 172 | $this->container = $container; 173 | 174 | return $previous; 175 | } 176 | } 177 | 178 | class TestService2 extends ParentTestService2 implements ServiceSubscriberInterface 179 | { 180 | use ServiceMethodsSubscriberTrait; 181 | } 182 | -------------------------------------------------------------------------------- /Tests/Service/ServiceSubscriberTraitTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Tests\Service; 13 | 14 | use PHPUnit\Framework\TestCase; 15 | use Psr\Container\ContainerInterface; 16 | use Symfony\Contracts\Service\Attribute\Required; 17 | use Symfony\Contracts\Service\Attribute\SubscribedService; 18 | use Symfony\Contracts\Service\ServiceLocatorTrait; 19 | use Symfony\Contracts\Service\ServiceSubscriberInterface; 20 | use Symfony\Contracts\Service\ServiceSubscriberTrait; 21 | 22 | /** 23 | * @group legacy 24 | */ 25 | class ServiceSubscriberTraitTest extends TestCase 26 | { 27 | public static function setUpBeforeClass(): void 28 | { 29 | class_exists(LegacyTestService::class); 30 | } 31 | 32 | public function testMethodsOnParentsAndChildrenAreIgnoredInGetSubscribedServices() 33 | { 34 | $expected = [ 35 | LegacyTestService::class.'::aService' => Service2::class, 36 | LegacyTestService::class.'::nullableInAttribute' => '?'.Service2::class, 37 | LegacyTestService::class.'::nullableReturnType' => '?'.Service2::class, 38 | new SubscribedService(LegacyTestService::class.'::withAttribute', Service2::class, true, new Required()), 39 | ]; 40 | 41 | $this->assertEquals($expected, LegacyChildTestService::getSubscribedServices()); 42 | } 43 | 44 | public function testSetContainerIsCalledOnParent() 45 | { 46 | $container = new class([]) implements ContainerInterface { 47 | use ServiceLocatorTrait; 48 | }; 49 | 50 | $this->assertSame($container, (new LegacyTestService())->setContainer($container)); 51 | } 52 | 53 | public function testParentNotCalledIfHasMagicCall() 54 | { 55 | $container = new class([]) implements ContainerInterface { 56 | use ServiceLocatorTrait; 57 | }; 58 | $service = new class extends LegacyParentWithMagicCall { 59 | use ServiceSubscriberTrait; 60 | 61 | private $container; 62 | }; 63 | 64 | $this->assertNull($service->setContainer($container)); 65 | $this->assertSame([], $service::getSubscribedServices()); 66 | } 67 | 68 | public function testParentNotCalledIfNoParent() 69 | { 70 | $container = new class([]) implements ContainerInterface { 71 | use ServiceLocatorTrait; 72 | }; 73 | $service = new class { 74 | use ServiceSubscriberTrait; 75 | 76 | private $container; 77 | }; 78 | 79 | $this->assertNull($service->setContainer($container)); 80 | $this->assertSame([], $service::getSubscribedServices()); 81 | } 82 | 83 | public function testSetContainerCalledFirstOnParent() 84 | { 85 | $container1 = new class([]) implements ContainerInterface { 86 | use ServiceLocatorTrait; 87 | }; 88 | $container2 = clone $container1; 89 | 90 | $testService = new class extends LegacyParentTestService2 implements ServiceSubscriberInterface { 91 | use ServiceSubscriberTrait; 92 | }; 93 | $this->assertNull($testService->setContainer($container1)); 94 | $this->assertSame($container1, $testService->setContainer($container2)); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Translation/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | The changelog is maintained for all Symfony contracts at the following URL: 5 | https://github.com/symfony/contracts/blob/main/CHANGELOG.md 6 | -------------------------------------------------------------------------------- /Translation/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /Translation/LocaleAwareInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation; 13 | 14 | interface LocaleAwareInterface 15 | { 16 | /** 17 | * Sets the current locale. 18 | * 19 | * @return void 20 | * 21 | * @throws \InvalidArgumentException If the locale contains invalid characters 22 | */ 23 | public function setLocale(string $locale); 24 | 25 | /** 26 | * Returns the current locale. 27 | */ 28 | public function getLocale(): string; 29 | } 30 | -------------------------------------------------------------------------------- /Translation/README.md: -------------------------------------------------------------------------------- 1 | Symfony Translation Contracts 2 | ============================= 3 | 4 | A set of abstractions extracted out of the Symfony components. 5 | 6 | Can be used to build on semantics that the Symfony components proved useful and 7 | that already have battle tested implementations. 8 | 9 | See https://github.com/symfony/contracts/blob/main/README.md for more information. 10 | -------------------------------------------------------------------------------- /Translation/Test/TranslatorTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation\Test; 13 | 14 | use PHPUnit\Framework\Attributes\DataProvider; 15 | use PHPUnit\Framework\Attributes\RequiresPhpExtension; 16 | use PHPUnit\Framework\TestCase; 17 | use Symfony\Contracts\Translation\TranslatorInterface; 18 | use Symfony\Contracts\Translation\TranslatorTrait; 19 | 20 | /** 21 | * Test should cover all languages mentioned on http://translate.sourceforge.net/wiki/l10n/pluralforms 22 | * and Plural forms mentioned on http://www.gnu.org/software/gettext/manual/gettext.html#Plural-forms. 23 | * 24 | * See also https://developer.mozilla.org/en/Localization_and_Plurals which mentions 15 rules having a maximum of 6 forms. 25 | * The mozilla code is also interesting to check for. 26 | * 27 | * As mentioned by chx http://drupal.org/node/1273968 we can cover all by testing number from 0 to 199 28 | * 29 | * The goal to cover all languages is to far fetched so this test case is smaller. 30 | * 31 | * @author Clemens Tolboom clemens@build2be.nl 32 | */ 33 | class TranslatorTest extends TestCase 34 | { 35 | private string $defaultLocale; 36 | 37 | protected function setUp(): void 38 | { 39 | $this->defaultLocale = \Locale::getDefault(); 40 | \Locale::setDefault('en'); 41 | } 42 | 43 | protected function tearDown(): void 44 | { 45 | \Locale::setDefault($this->defaultLocale); 46 | } 47 | 48 | public function getTranslator(): TranslatorInterface 49 | { 50 | return new class implements TranslatorInterface { 51 | use TranslatorTrait; 52 | }; 53 | } 54 | 55 | /** 56 | * @dataProvider getTransTests 57 | */ 58 | #[DataProvider('getTransTests')] 59 | public function testTrans($expected, $id, $parameters) 60 | { 61 | $translator = $this->getTranslator(); 62 | 63 | $this->assertEquals($expected, $translator->trans($id, $parameters)); 64 | } 65 | 66 | /** 67 | * @dataProvider getTransChoiceTests 68 | */ 69 | #[DataProvider('getTransChoiceTests')] 70 | public function testTransChoiceWithExplicitLocale($expected, $id, $number) 71 | { 72 | $translator = $this->getTranslator(); 73 | 74 | $this->assertEquals($expected, $translator->trans($id, ['%count%' => $number])); 75 | } 76 | 77 | /** 78 | * @requires extension intl 79 | * 80 | * @dataProvider getTransChoiceTests 81 | */ 82 | #[DataProvider('getTransChoiceTests')] 83 | #[RequiresPhpExtension('intl')] 84 | public function testTransChoiceWithDefaultLocale($expected, $id, $number) 85 | { 86 | $translator = $this->getTranslator(); 87 | 88 | $this->assertEquals($expected, $translator->trans($id, ['%count%' => $number])); 89 | } 90 | 91 | /** 92 | * @dataProvider getTransChoiceTests 93 | */ 94 | #[DataProvider('getTransChoiceTests')] 95 | public function testTransChoiceWithEnUsPosix($expected, $id, $number) 96 | { 97 | $translator = $this->getTranslator(); 98 | $translator->setLocale('en_US_POSIX'); 99 | 100 | $this->assertEquals($expected, $translator->trans($id, ['%count%' => $number])); 101 | } 102 | 103 | public function testGetSetLocale() 104 | { 105 | $translator = $this->getTranslator(); 106 | 107 | $this->assertEquals('en', $translator->getLocale()); 108 | } 109 | 110 | /** 111 | * @requires extension intl 112 | */ 113 | #[RequiresPhpExtension('intl')] 114 | public function testGetLocaleReturnsDefaultLocaleIfNotSet() 115 | { 116 | $translator = $this->getTranslator(); 117 | 118 | \Locale::setDefault('pt_BR'); 119 | $this->assertEquals('pt_BR', $translator->getLocale()); 120 | 121 | \Locale::setDefault('en'); 122 | $this->assertEquals('en', $translator->getLocale()); 123 | } 124 | 125 | public static function getTransTests() 126 | { 127 | return [ 128 | ['Symfony is great!', 'Symfony is great!', []], 129 | ['Symfony is awesome!', 'Symfony is %what%!', ['%what%' => 'awesome']], 130 | ]; 131 | } 132 | 133 | public static function getTransChoiceTests() 134 | { 135 | return [ 136 | ['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], 137 | ['There is one apple', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 1], 138 | ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10], 139 | ['There are 0 apples', 'There is 1 apple|There are %count% apples', 0], 140 | ['There is 1 apple', 'There is 1 apple|There are %count% apples', 1], 141 | ['There are 10 apples', 'There is 1 apple|There are %count% apples', 10], 142 | // custom validation messages may be coded with a fixed value 143 | ['There are 2 apples', 'There are 2 apples', 2], 144 | ]; 145 | } 146 | 147 | /** 148 | * @dataProvider getInterval 149 | */ 150 | #[DataProvider('getInterval')] 151 | public function testInterval($expected, $number, $interval) 152 | { 153 | $translator = $this->getTranslator(); 154 | 155 | $this->assertEquals($expected, $translator->trans($interval.' foo|[1,Inf[ bar', ['%count%' => $number])); 156 | } 157 | 158 | public static function getInterval() 159 | { 160 | return [ 161 | ['foo', 3, '{1,2, 3 ,4}'], 162 | ['bar', 10, '{1,2, 3 ,4}'], 163 | ['bar', 3, '[1,2]'], 164 | ['foo', 1, '[1,2]'], 165 | ['foo', 2, '[1,2]'], 166 | ['bar', 1, ']1,2['], 167 | ['bar', 2, ']1,2['], 168 | ['foo', log(0), '[-Inf,2['], 169 | ['foo', -log(0), '[-2,+Inf]'], 170 | ]; 171 | } 172 | 173 | /** 174 | * @dataProvider getChooseTests 175 | */ 176 | #[DataProvider('getChooseTests')] 177 | public function testChoose($expected, $id, $number, $locale = null) 178 | { 179 | $translator = $this->getTranslator(); 180 | 181 | $this->assertEquals($expected, $translator->trans($id, ['%count%' => $number], null, $locale)); 182 | } 183 | 184 | public function testReturnMessageIfExactlyOneStandardRuleIsGiven() 185 | { 186 | $translator = $this->getTranslator(); 187 | 188 | $this->assertEquals('There are two apples', $translator->trans('There are two apples', ['%count%' => 2])); 189 | } 190 | 191 | /** 192 | * @dataProvider getNonMatchingMessages 193 | */ 194 | #[DataProvider('getNonMatchingMessages')] 195 | public function testThrowExceptionIfMatchingMessageCannotBeFound($id, $number) 196 | { 197 | $translator = $this->getTranslator(); 198 | 199 | $this->expectException(\InvalidArgumentException::class); 200 | 201 | $translator->trans($id, ['%count%' => $number]); 202 | } 203 | 204 | public static function getNonMatchingMessages() 205 | { 206 | return [ 207 | ['{0} There are no apples|{1} There is one apple', 2], 208 | ['{1} There is one apple|]1,Inf] There are %count% apples', 0], 209 | ['{1} There is one apple|]2,Inf] There are %count% apples', 2], 210 | ['{0} There are no apples|There is one apple', 2], 211 | ]; 212 | } 213 | 214 | public static function getChooseTests() 215 | { 216 | return [ 217 | ['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], 218 | ['There are no apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], 219 | ['There are no apples', '{0}There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 0], 220 | 221 | ['There is one apple', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 1], 222 | 223 | ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10], 224 | ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf]There are %count% apples', 10], 225 | ['There are 10 apples', '{0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples', 10], 226 | 227 | ['There are 0 apples', 'There is one apple|There are %count% apples', 0], 228 | ['There is one apple', 'There is one apple|There are %count% apples', 1], 229 | ['There are 10 apples', 'There is one apple|There are %count% apples', 10], 230 | 231 | ['There are 0 apples', 'one: There is one apple|more: There are %count% apples', 0], 232 | ['There is one apple', 'one: There is one apple|more: There are %count% apples', 1], 233 | ['There are 10 apples', 'one: There is one apple|more: There are %count% apples', 10], 234 | 235 | ['There are no apples', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 0], 236 | ['There is one apple', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 1], 237 | ['There are 10 apples', '{0} There are no apples|one: There is one apple|more: There are %count% apples', 10], 238 | 239 | ['', '{0}|{1} There is one apple|]1,Inf] There are %count% apples', 0], 240 | ['', '{0} There are no apples|{1}|]1,Inf] There are %count% apples', 1], 241 | 242 | // Indexed only tests which are Gettext PoFile* compatible strings. 243 | ['There are 0 apples', 'There is one apple|There are %count% apples', 0], 244 | ['There is one apple', 'There is one apple|There are %count% apples', 1], 245 | ['There are 2 apples', 'There is one apple|There are %count% apples', 2], 246 | 247 | // Tests for float numbers 248 | ['There is almost one apple', '{0} There are no apples|]0,1[ There is almost one apple|{1} There is one apple|[1,Inf] There is more than one apple', 0.7], 249 | ['There is one apple', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 1], 250 | ['There is more than one apple', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 1.7], 251 | ['There are no apples', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0], 252 | ['There are no apples', '{0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0.0], 253 | ['There are no apples', '{0.0} There are no apples|]0,1[There are %count% apples|{1} There is one apple|[1,Inf] There is more than one apple', 0], 254 | 255 | // Test texts with new-lines 256 | // with double-quotes and \n in id & double-quotes and actual newlines in text 257 | ["This is a text with a\n new-line in it. Selector = 0.", '{0}This is a text with a 258 | new-line in it. Selector = 0.|{1}This is a text with a 259 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 260 | new-line in it. Selector > 1.', 0], 261 | // with double-quotes and \n in id and single-quotes and actual newlines in text 262 | ["This is a text with a\n new-line in it. Selector = 1.", '{0}This is a text with a 263 | new-line in it. Selector = 0.|{1}This is a text with a 264 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 265 | new-line in it. Selector > 1.', 1], 266 | ["This is a text with a\n new-line in it. Selector > 1.", '{0}This is a text with a 267 | new-line in it. Selector = 0.|{1}This is a text with a 268 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 269 | new-line in it. Selector > 1.', 5], 270 | // with double-quotes and id split across lines 271 | ['This is a text with a 272 | new-line in it. Selector = 1.', '{0}This is a text with a 273 | new-line in it. Selector = 0.|{1}This is a text with a 274 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 275 | new-line in it. Selector > 1.', 1], 276 | // with single-quotes and id split across lines 277 | ['This is a text with a 278 | new-line in it. Selector > 1.', '{0}This is a text with a 279 | new-line in it. Selector = 0.|{1}This is a text with a 280 | new-line in it. Selector = 1.|[1,Inf]This is a text with a 281 | new-line in it. Selector > 1.', 5], 282 | // with single-quotes and \n in text 283 | ['This is a text with a\nnew-line in it. Selector = 0.', '{0}This is a text with a\nnew-line in it. Selector = 0.|{1}This is a text with a\nnew-line in it. Selector = 1.|[1,Inf]This is a text with a\nnew-line in it. Selector > 1.', 0], 284 | // with double-quotes and id split across lines 285 | ["This is a text with a\nnew-line in it. Selector = 1.", "{0}This is a text with a\nnew-line in it. Selector = 0.|{1}This is a text with a\nnew-line in it. Selector = 1.|[1,Inf]This is a text with a\nnew-line in it. Selector > 1.", 1], 286 | // escape pipe 287 | ['This is a text with | in it. Selector = 0.', '{0}This is a text with || in it. Selector = 0.|{1}This is a text with || in it. Selector = 1.', 0], 288 | // Empty plural set (2 plural forms) from a .PO file 289 | ['', '|', 1], 290 | // Empty plural set (3 plural forms) from a .PO file 291 | ['', '||', 1], 292 | 293 | // Floating values 294 | ['1.5 liters', '%count% liter|%count% liters', 1.5], 295 | ['1.5 litre', '%count% litre|%count% litres', 1.5, 'fr'], 296 | 297 | // Negative values 298 | ['-1 degree', '%count% degree|%count% degrees', -1], 299 | ['-1 degré', '%count% degré|%count% degrés', -1], 300 | ['-1.5 degrees', '%count% degree|%count% degrees', -1.5], 301 | ['-1.5 degré', '%count% degré|%count% degrés', -1.5, 'fr'], 302 | ['-2 degrees', '%count% degree|%count% degrees', -2], 303 | ['-2 degrés', '%count% degré|%count% degrés', -2], 304 | ]; 305 | } 306 | 307 | /** 308 | * @dataProvider failingLangcodes 309 | */ 310 | #[DataProvider('failingLangcodes')] 311 | public function testFailedLangcodes($nplural, $langCodes) 312 | { 313 | $matrix = $this->generateTestData($langCodes); 314 | $this->validateMatrix($nplural, $matrix, false); 315 | } 316 | 317 | /** 318 | * @dataProvider successLangcodes 319 | */ 320 | #[DataProvider('successLangcodes')] 321 | public function testLangcodes($nplural, $langCodes) 322 | { 323 | $matrix = $this->generateTestData($langCodes); 324 | $this->validateMatrix($nplural, $matrix); 325 | } 326 | 327 | /** 328 | * This array should contain all currently known langcodes. 329 | * 330 | * As it is impossible to have this ever complete we should try as hard as possible to have it almost complete. 331 | */ 332 | public static function successLangcodes(): array 333 | { 334 | return [ 335 | ['1', ['ay', 'bo', 'cgg', 'dz', 'id', 'ja', 'jbo', 'ka', 'kk', 'km', 'ko', 'ky']], 336 | ['2', ['nl', 'fr', 'en', 'de', 'de_GE', 'hy', 'hy_AM', 'en_US_POSIX']], 337 | ['3', ['be', 'bs', 'cs', 'hr']], 338 | ['4', ['cy', 'mt', 'sl']], 339 | ['6', ['ar']], 340 | ]; 341 | } 342 | 343 | /** 344 | * This array should be at least empty within the near future. 345 | * 346 | * This both depends on a complete list trying to add above as understanding 347 | * the plural rules of the current failing languages. 348 | * 349 | * @return array with nplural together with langcodes 350 | */ 351 | public static function failingLangcodes(): array 352 | { 353 | return [ 354 | ['1', ['fa']], 355 | ['2', ['jbo']], 356 | ['3', ['cbs']], 357 | ['4', ['gd', 'kw']], 358 | ['5', ['ga']], 359 | ]; 360 | } 361 | 362 | /** 363 | * We validate only on the plural coverage. Thus the real rules is not tested. 364 | * 365 | * @param string $nplural Plural expected 366 | * @param array $matrix Containing langcodes and their plural index values 367 | */ 368 | protected function validateMatrix(string $nplural, array $matrix, bool $expectSuccess = true) 369 | { 370 | foreach ($matrix as $langCode => $data) { 371 | $indexes = array_flip($data); 372 | if ($expectSuccess) { 373 | $this->assertCount($nplural, $indexes, "Langcode '$langCode' has '$nplural' plural forms."); 374 | } else { 375 | $this->assertNotCount($nplural, $indexes, "Langcode '$langCode' has '$nplural' plural forms."); 376 | } 377 | } 378 | } 379 | 380 | protected function generateTestData($langCodes) 381 | { 382 | $translator = new class { 383 | use TranslatorTrait { 384 | getPluralizationRule as public; 385 | } 386 | }; 387 | 388 | $matrix = []; 389 | foreach ($langCodes as $langCode) { 390 | for ($count = 0; $count < 200; ++$count) { 391 | $plural = $translator->getPluralizationRule($count, $langCode); 392 | $matrix[$langCode][$count] = $plural; 393 | } 394 | } 395 | 396 | return $matrix; 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /Translation/TranslatableInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation; 13 | 14 | /** 15 | * @author Nicolas Grekas 16 | */ 17 | interface TranslatableInterface 18 | { 19 | public function trans(TranslatorInterface $translator, ?string $locale = null): string; 20 | } 21 | -------------------------------------------------------------------------------- /Translation/TranslatorInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation; 13 | 14 | /** 15 | * @author Fabien Potencier 16 | */ 17 | interface TranslatorInterface 18 | { 19 | /** 20 | * Translates the given message. 21 | * 22 | * When a number is provided as a parameter named "%count%", the message is parsed for plural 23 | * forms and a translation is chosen according to this number using the following rules: 24 | * 25 | * Given a message with different plural translations separated by a 26 | * pipe (|), this method returns the correct portion of the message based 27 | * on the given number, locale and the pluralization rules in the message 28 | * itself. 29 | * 30 | * The message supports two different types of pluralization rules: 31 | * 32 | * interval: {0} There are no apples|{1} There is one apple|]1,Inf] There are %count% apples 33 | * indexed: There is one apple|There are %count% apples 34 | * 35 | * The indexed solution can also contain labels (e.g. one: There is one apple). 36 | * This is purely for making the translations more clear - it does not 37 | * affect the functionality. 38 | * 39 | * The two methods can also be mixed: 40 | * {0} There are no apples|one: There is one apple|more: There are %count% apples 41 | * 42 | * An interval can represent a finite set of numbers: 43 | * {1,2,3,4} 44 | * 45 | * An interval can represent numbers between two numbers: 46 | * [1, +Inf] 47 | * ]-1,2[ 48 | * 49 | * The left delimiter can be [ (inclusive) or ] (exclusive). 50 | * The right delimiter can be [ (exclusive) or ] (inclusive). 51 | * Beside numbers, you can use -Inf and +Inf for the infinite. 52 | * 53 | * @see https://en.wikipedia.org/wiki/ISO_31-11 54 | * 55 | * @param string $id The message id (may also be an object that can be cast to string) 56 | * @param array $parameters An array of parameters for the message 57 | * @param string|null $domain The domain for the message or null to use the default 58 | * @param string|null $locale The locale or null to use the default 59 | * 60 | * @throws \InvalidArgumentException If the locale contains invalid characters 61 | */ 62 | public function trans(string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string; 63 | 64 | /** 65 | * Returns the default locale. 66 | */ 67 | public function getLocale(): string; 68 | } 69 | -------------------------------------------------------------------------------- /Translation/TranslatorTrait.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Contracts\Translation; 13 | 14 | use Symfony\Component\Translation\Exception\InvalidArgumentException; 15 | 16 | /** 17 | * A trait to help implement TranslatorInterface and LocaleAwareInterface. 18 | * 19 | * @author Fabien Potencier 20 | */ 21 | trait TranslatorTrait 22 | { 23 | private ?string $locale = null; 24 | 25 | /** 26 | * @return void 27 | */ 28 | public function setLocale(string $locale) 29 | { 30 | $this->locale = $locale; 31 | } 32 | 33 | public function getLocale(): string 34 | { 35 | return $this->locale ?: (class_exists(\Locale::class) ? \Locale::getDefault() : 'en'); 36 | } 37 | 38 | public function trans(?string $id, array $parameters = [], ?string $domain = null, ?string $locale = null): string 39 | { 40 | if (null === $id || '' === $id) { 41 | return ''; 42 | } 43 | 44 | if (!isset($parameters['%count%']) || !is_numeric($parameters['%count%'])) { 45 | return strtr($id, $parameters); 46 | } 47 | 48 | $number = (float) $parameters['%count%']; 49 | $locale = $locale ?: $this->getLocale(); 50 | 51 | $parts = []; 52 | if (preg_match('/^\|++$/', $id)) { 53 | $parts = explode('|', $id); 54 | } elseif (preg_match_all('/(?:\|\||[^\|])++/', $id, $matches)) { 55 | $parts = $matches[0]; 56 | } 57 | 58 | $intervalRegexp = <<<'EOF' 59 | /^(?P 60 | ({\s* 61 | (\-?\d+(\.\d+)?[\s*,\s*\-?\d+(\.\d+)?]*) 62 | \s*}) 63 | 64 | | 65 | 66 | (?P[\[\]]) 67 | \s* 68 | (?P-Inf|\-?\d+(\.\d+)?) 69 | \s*,\s* 70 | (?P\+?Inf|\-?\d+(\.\d+)?) 71 | \s* 72 | (?P[\[\]]) 73 | )\s*(?P.*?)$/xs 74 | EOF; 75 | 76 | $standardRules = []; 77 | foreach ($parts as $part) { 78 | $part = trim(str_replace('||', '|', $part)); 79 | 80 | // try to match an explicit rule, then fallback to the standard ones 81 | if (preg_match($intervalRegexp, $part, $matches)) { 82 | if ($matches[2]) { 83 | foreach (explode(',', $matches[3]) as $n) { 84 | if ($number == $n) { 85 | return strtr($matches['message'], $parameters); 86 | } 87 | } 88 | } else { 89 | $leftNumber = '-Inf' === $matches['left'] ? -\INF : (float) $matches['left']; 90 | $rightNumber = is_numeric($matches['right']) ? (float) $matches['right'] : \INF; 91 | 92 | if (('[' === $matches['left_delimiter'] ? $number >= $leftNumber : $number > $leftNumber) 93 | && (']' === $matches['right_delimiter'] ? $number <= $rightNumber : $number < $rightNumber) 94 | ) { 95 | return strtr($matches['message'], $parameters); 96 | } 97 | } 98 | } elseif (preg_match('/^\w+\:\s*(.*?)$/', $part, $matches)) { 99 | $standardRules[] = $matches[1]; 100 | } else { 101 | $standardRules[] = $part; 102 | } 103 | } 104 | 105 | $position = $this->getPluralizationRule($number, $locale); 106 | 107 | if (!isset($standardRules[$position])) { 108 | // when there's exactly one rule given, and that rule is a standard 109 | // rule, use this rule 110 | if (1 === \count($parts) && isset($standardRules[0])) { 111 | return strtr($standardRules[0], $parameters); 112 | } 113 | 114 | $message = \sprintf('Unable to choose a translation for "%s" with locale "%s" for value "%d". Double check that this translation has the correct plural options (e.g. "There is one apple|There are %%count%% apples").', $id, $locale, $number); 115 | 116 | if (class_exists(InvalidArgumentException::class)) { 117 | throw new InvalidArgumentException($message); 118 | } 119 | 120 | throw new \InvalidArgumentException($message); 121 | } 122 | 123 | return strtr($standardRules[$position], $parameters); 124 | } 125 | 126 | /** 127 | * Returns the plural position to use for the given locale and number. 128 | * 129 | * The plural rules are derived from code of the Zend Framework (2010-09-25), 130 | * which is subject to the new BSD license (http://framework.zend.com/license/new-bsd). 131 | * Copyright (c) 2005-2010 Zend Technologies USA Inc. (http://www.zend.com) 132 | */ 133 | private function getPluralizationRule(float $number, string $locale): int 134 | { 135 | $number = abs($number); 136 | 137 | return match ('pt_BR' !== $locale && 'en_US_POSIX' !== $locale && \strlen($locale) > 3 ? substr($locale, 0, strrpos($locale, '_')) : $locale) { 138 | 'af', 139 | 'bn', 140 | 'bg', 141 | 'ca', 142 | 'da', 143 | 'de', 144 | 'el', 145 | 'en', 146 | 'en_US_POSIX', 147 | 'eo', 148 | 'es', 149 | 'et', 150 | 'eu', 151 | 'fa', 152 | 'fi', 153 | 'fo', 154 | 'fur', 155 | 'fy', 156 | 'gl', 157 | 'gu', 158 | 'ha', 159 | 'he', 160 | 'hu', 161 | 'is', 162 | 'it', 163 | 'ku', 164 | 'lb', 165 | 'ml', 166 | 'mn', 167 | 'mr', 168 | 'nah', 169 | 'nb', 170 | 'ne', 171 | 'nl', 172 | 'nn', 173 | 'no', 174 | 'oc', 175 | 'om', 176 | 'or', 177 | 'pa', 178 | 'pap', 179 | 'ps', 180 | 'pt', 181 | 'so', 182 | 'sq', 183 | 'sv', 184 | 'sw', 185 | 'ta', 186 | 'te', 187 | 'tk', 188 | 'ur', 189 | 'zu' => (1 == $number) ? 0 : 1, 190 | 'am', 191 | 'bh', 192 | 'fil', 193 | 'fr', 194 | 'gun', 195 | 'hi', 196 | 'hy', 197 | 'ln', 198 | 'mg', 199 | 'nso', 200 | 'pt_BR', 201 | 'ti', 202 | 'wa' => ($number < 2) ? 0 : 1, 203 | 'be', 204 | 'bs', 205 | 'hr', 206 | 'ru', 207 | 'sh', 208 | 'sr', 209 | 'uk' => ((1 == $number % 10) && (11 != $number % 100)) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2), 210 | 'cs', 211 | 'sk' => (1 == $number) ? 0 : ((($number >= 2) && ($number <= 4)) ? 1 : 2), 212 | 'ga' => (1 == $number) ? 0 : ((2 == $number) ? 1 : 2), 213 | 'lt' => ((1 == $number % 10) && (11 != $number % 100)) ? 0 : ((($number % 10 >= 2) && (($number % 100 < 10) || ($number % 100 >= 20))) ? 1 : 2), 214 | 'sl' => (1 == $number % 100) ? 0 : ((2 == $number % 100) ? 1 : (((3 == $number % 100) || (4 == $number % 100)) ? 2 : 3)), 215 | 'mk' => (1 == $number % 10) ? 0 : 1, 216 | 'mt' => (1 == $number) ? 0 : (((0 == $number) || (($number % 100 > 1) && ($number % 100 < 11))) ? 1 : ((($number % 100 > 10) && ($number % 100 < 20)) ? 2 : 3)), 217 | 'lv' => (0 == $number) ? 0 : (((1 == $number % 10) && (11 != $number % 100)) ? 1 : 2), 218 | 'pl' => (1 == $number) ? 0 : ((($number % 10 >= 2) && ($number % 10 <= 4) && (($number % 100 < 12) || ($number % 100 > 14))) ? 1 : 2), 219 | 'cy' => (1 == $number) ? 0 : ((2 == $number) ? 1 : (((8 == $number) || (11 == $number)) ? 2 : 3)), 220 | 'ro' => (1 == $number) ? 0 : (((0 == $number) || (($number % 100 > 0) && ($number % 100 < 20))) ? 1 : 2), 221 | 'ar' => (0 == $number) ? 0 : ((1 == $number) ? 1 : ((2 == $number) ? 2 : ((($number % 100 >= 3) && ($number % 100 <= 10)) ? 3 : ((($number % 100 >= 11) && ($number % 100 <= 99)) ? 4 : 5)))), 222 | default => 0, 223 | }; 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /Translation/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/translation-contracts", 3 | "type": "library", 4 | "description": "Generic abstractions related to translation", 5 | "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Nicolas Grekas", 11 | "email": "p@tchwork.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1" 20 | }, 21 | "autoload": { 22 | "psr-4": { "Symfony\\Contracts\\Translation\\": "" }, 23 | "exclude-from-classmap": [ 24 | "/Test/" 25 | ] 26 | }, 27 | "minimum-stability": "dev", 28 | "extra": { 29 | "branch-alias": { 30 | "dev-main": "3.6-dev" 31 | }, 32 | "thanks": { 33 | "name": "symfony/contracts", 34 | "url": "https://github.com/symfony/contracts" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/contracts", 3 | "type": "library", 4 | "description": "A set of abstractions extracted out of the Symfony components", 5 | "keywords": ["abstractions", "contracts", "decoupling", "interfaces", "interoperability", "standards", "dev"], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Nicolas Grekas", 11 | "email": "p@tchwork.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.1", 20 | "psr/cache": "^3.0", 21 | "psr/container": "^1.1|^2.0", 22 | "psr/event-dispatcher": "^1.0" 23 | }, 24 | "require-dev": { 25 | "symfony/polyfill-intl-idn": "^1.10" 26 | }, 27 | "conflict": { 28 | "ext-psr": "<1.1|>=2" 29 | }, 30 | "replace": { 31 | "symfony/cache-contracts": "self.version", 32 | "symfony/deprecation-contracts": "self.version", 33 | "symfony/event-dispatcher-contracts": "self.version", 34 | "symfony/http-client-contracts": "self.version", 35 | "symfony/service-contracts": "self.version", 36 | "symfony/translation-contracts": "self.version" 37 | }, 38 | "autoload": { 39 | "psr-4": { "Symfony\\Contracts\\": "" }, 40 | "files": [ "Deprecation/function.php" ], 41 | "exclude-from-classmap": [ 42 | "**/Tests/" 43 | ] 44 | }, 45 | "minimum-stability": "dev", 46 | "extra": { 47 | "branch-alias": { 48 | "dev-main": "3.6-dev" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ./Tests/ 18 | ./Service/Test/ 19 | ./Translation/Test/ 20 | 21 | 22 | 23 | 24 | 25 | ./ 26 | 27 | 28 | ./Tests 29 | ./Service/Test/ 30 | ./Translation/Test/ 31 | ./vendor 32 | 33 | 34 | 35 | 36 | --------------------------------------------------------------------------------