├── 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 |
--------------------------------------------------------------------------------