Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
ServiceProviderContainer
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
5 / 5
26
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 get
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
12
 hasEntry
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 applyServiceExtensions
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
1<?php
2
3declare(strict_types=1);
4
5/**
6 * This file is part of php-fast-forward/container.
7 *
8 * This source file is subject to the license bundled
9 * with this source code in the file LICENSE.
10 *
11 * @copyright Copyright (c) 2025-2026 Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
12 * @license   https://opensource.org/licenses/MIT MIT License
13 *
14 * @see       https://github.com/php-fast-forward/container
15 * @see       https://github.com/php-fast-forward
16 * @see       https://datatracker.ietf.org/doc/html/rfc2119
17 */
18
19namespace FastForward\Container;
20
21use Throwable;
22use FastForward\Container\Exception\ContainerException;
23use FastForward\Container\Exception\NotFoundException;
24use FastForward\Container\Extension\ServiceExtension;
25use Interop\Container\ServiceProviderInterface;
26use Psr\Container\ContainerInterface as PsrContainerInterface;
27
28/**
29 * Implements a PSR-11 compliant dependency injection container using a container-interop/service-provider.
30 *
31 * This container SHALL resolve services by delegating to the factories and extensions defined in the
32 * provided ServiceProviderInterface instance.
33 *
34 * Services are lazily instantiated on first request and
35 * cached for subsequent retrieval, enforcing singleton-like behavior within the container scope.
36 *
37 * The container supports service extension mechanisms by allowing callable extensions to modify or
38 * enhance services after construction, based on the service identifier or its concrete class name.
39 *
40 * If an optional wrapper container is provided, it SHALL be passed to service factories and extensions,
41 * allowing for delegation or decoration of service resolution. If omitted, the container defaults to itself.
42 */
43 class ServiceProviderContainer implements ContainerInterface
44{
45    /**
46     * The container instance used for service resolution and extension application.
47     *
48     * This property MAY reference another container for delegation, or default to this container instance.
49     */
50    private  PsrContainerInterface $wrapperContainer;
51
52    /**
53     * Cache of resolved services keyed by their identifier or class name.
54     *
55     * This array SHALL store already constructed services to enforce singleton-like behavior within the container scope.
56     *
57     * @var array<string, mixed>
58     */
59    private array $cache = [];
60
61    /**
62     * Constructs a new ServiceProviderContainer instance.
63     *
64     * This constructor SHALL initialize the container with a service provider and an optional delegating container.
65     * If no wrapper container is provided, the container SHALL delegate to itself.
66     *
67     * @param ServiceProviderInterface $serviceProvider the service provider supplying factories and extensions
68     * @param PsrContainerInterface|null $wrapperContainer An optional container for delegation. Defaults to self.
69     */
70    public function __construct(
71        private  ServiceProviderInterface $serviceProvider,
72        ?PsrContainerInterface $wrapperContainer = null,
73    ) {
74        $this->wrapperContainer = $wrapperContainer ?? $this;
75    }
76
77    /**
78     * Determines if the container can return an entry for the given identifier.
79     *
80     * This method MUST return true for cached entries, callable factories, or callable ID-based extensions.
81     * It SHALL NOT execute factories or extensions to discover an entry.
82     *
83     * @param string $id identifier of the entry to look for
84     *
85     * @return bool true if the entry exists, false otherwise
86     *
87     * @throws ContainerException if provider metadata cannot be read
88     */
89    public function has(string $id): bool
90    {
91        if ($this->hasEntry($id)) {
92            return true;
93        }
94
95        try {
96            return $this->hasEntry($id, $this->serviceProvider->getFactories())
97                || $this->hasEntry($id, extensions: $this->serviceProvider->getExtensions());
98        } catch (Throwable $throwable) {
99            throw ContainerException::forInvalidService($id, $throwable);
100        }
101    }
102
103    /**
104     * Retrieves a service from the container by its identifier.
105     *
106     * This method SHALL return a cached instance if available, otherwise it resolves
107     * the service using the factory provided by the service provider.
108     *
109     * Extensions SHALL receive the previous value and may replace it. Without a factory,
110     * an ID-based extension receives null. The final value SHALL be cached, including null.
111     *
112     * @param string $id the identifier of the service to retrieve
113     *
114     * @return mixed the service instance associated with the identifier
115     *
116     * @throws NotFoundException if neither a callable factory nor an ID-based extension exists
117     * @throws ContainerException if factory or extension resolution fails
118     */
119    public function get(string $id): mixed
120    {
121        if (\array_key_exists($id, $this->cache)) {
122            return $this->cache[$id];
123        }
124
125        try {
126            $factories = $this->serviceProvider->getFactories();
127            $extensions = $this->serviceProvider->getExtensions();
128        } catch (Throwable $throwable) {
129            throw ContainerException::forInvalidService($id, $throwable);
130        }
131
132        $factory = $factories[$id] ?? null;
133
134        if (! \is_callable($factory) && ! \is_callable($extensions[$id] ?? null)) {
135            throw NotFoundException::forServiceID($id);
136        }
137
138        try {
139            $service = \is_callable($factory) ? $factory($this->wrapperContainer) : null;
140            $service = $this->applyServiceExtensions($id, $service, $extensions);
141            $alias = \is_object($service) ? $service::class : null;
142
143            if (null !== $alias && ($id === $alias || $this->hasEntry($alias, $factories, $extensions))) {
144                $alias = null;
145            }
146        } catch (Throwable $throwable) {
147            throw ContainerException::forInvalidService($id, $throwable);
148        }
149
150        $this->cache[$id] = $service;
151
152        if (null !== $alias) {
153            $this->cache[$alias] = $service;
154        }
155
156        return $service;
157    }
158
159    /**
160     * Checks existing entries using metadata already loaded by the caller.
161     *
162     * @param string $id the entry identifier
163     * @param array<string, mixed> $factories the available factory definitions
164     * @param array<string, mixed> $extensions the available extension definitions
165     *
166     * @return bool whether a resolved entry or callable definition exists
167     */
168    private function hasEntry(string $id, array $factories = [], array $extensions = []): bool
169    {
170        return \array_key_exists($id, $this->cache)
171            || \is_callable($factories[$id] ?? null)
172            || \is_callable($extensions[$id] ?? null);
173    }
174
175    /**
176     * Applies ID-based and concrete-class extensions, retaining each returned value.
177     *
178     * The ID-based extension SHALL run first. If its result is an object, an extension
179     * for that object's concrete class MAY run next. Invalid extensions SHALL be ignored.
180     *
181     * Extensions declared void SHALL retain the previous value for compatibility.
182     *
183     * @param string $id the identifier of the resolved service
184     * @param mixed $service the previous entry value, or null when no factory exists
185     * @param array<string, mixed> $extensions the provider's extension definitions
186     *
187     * @return mixed the final entry value
188     *
189     * @throws ContainerException if an extension callable fails during execution
190     */
191    private function applyServiceExtensions(string $id, mixed $service, array $extensions): mixed
192    {
193        if (\is_callable($extensions[$id] ?? null)) {
194            $service = (new ServiceExtension($extensions[$id]))($this->wrapperContainer, $service);
195        }
196
197        if (\is_object($service)
198            && $id !== $service::class
199            && ! \array_key_exists($service::class, $this->cache)
200            && \is_callable($extensions[$service::class] ?? null)
201        ) {
202            return (new ServiceExtension($extensions[$service::class]))($this->wrapperContainer, $service);
203        }
204
205        return $service;
206    }
207}