diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml new file mode 100644 index 0000000..834c8d9 --- /dev/null +++ b/.github/workflows/php.yml @@ -0,0 +1,34 @@ +name: PHP Unitary + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + env: + COMPOSER_ROOT_VERSION: 1.x-dev + + steps: + - uses: actions/checkout@v4 + + - name: Cache Composer packages + uses: actions/cache@v3 + with: + path: vendor + key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-php- + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run test suite + run: php vendor/bin/unitary \ No newline at end of file diff --git a/README.md b/README.md index 87e97c6..aee19b5 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,19 @@ composer require maplephp/emitron Emitron includes a robust request handler that executes PSR-15 middlewares in sequence, returning a fully PSR-7 compliant response. ```php -use MaplePHP\Emitron\RequestHandler;use MaplePHP\Http\Environment;use MaplePHP\Http\ServerRequest;use MaplePHP\Http\Uri; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use MaplePHP\Emitron\RequestHandler; +use MaplePHP\Emitron\Emitters\HttpEmitter; +use MaplePHP\Http\Environment; +use MaplePHP\Http\ServerRequest; +use MaplePHP\Http\Uri; +use MaplePHP\Emitron\Middlewares\{ + ContentLengthMiddleware, + GzipMiddleware, + HeadRequestMiddleware +}; +use App\Controllers\MyController; // Use MaplePHP HTTP library or any other PSR-7 implementation $env = new Environment(); @@ -48,8 +60,23 @@ $middlewares = [ ]; // Run the middleware stack -$handler = new RequestHandler($middlewares, $factory); + + +$factory = new ResponseFactory($bodyStream); +// $finalHandler = new ControllerRequestHandler($factory, [MyController::class, "index"]); +$finalHandler = new ControllerRequestHandler($factory, function(RequestInterface $request, ResponseInterface $response) { + $response->getBody()->write("Lorem ipsum dolor sit amet, consectetur adipiscing elit."); + return $response; +}); + +$handler = new RequestHandler($middlewares, $finalHandler); $response = $handler->handle($request); + +// Emit the execute headers and response correctly +//$emit = new CliEmitter($response, $request); +$emit = new HttpEmitter(); +$emit->emit($response, $request); + ``` Each middleware conforms to `Psr\Http\Server\MiddlewareInterface`, allowing you to plug in your own or third-party middlewares with no additional setup. diff --git a/composer.json b/composer.json index 23abc63..59dee55 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,9 @@ "homepage": "https://wazabii.se" } ], + "scripts": { + "test": "php vendor/bin/unitary" + }, "require": { "php": ">=8.2", "psr/http-server-middleware": "^1.0", @@ -37,15 +40,17 @@ "require-dev": { "maplephp/unitary": "^2.0" }, - "extra": { - "branch-alias": { - "dev-main": "1.x-dev" - } - }, "autoload": { "psr-4": { "MaplePHP\\Emitron\\": "src" } }, - "minimum-stability": "dev" + "extra": { + "branch-alias": { + "dev-main": "1.x-dev", + "dev-develop": "1.x-dev" + } + }, + "minimum-stability": "dev", + "prefer-stable": true } diff --git a/src/AbstractConfigProps.php b/src/AbstractConfigProps.php index 4c7a7e7..316c5dd 100644 --- a/src/AbstractConfigProps.php +++ b/src/AbstractConfigProps.php @@ -11,8 +11,12 @@ abstract class AbstractConfigProps implements ConfigPropsInterface { /** @var array */ public array $missingProps = []; + public ?string $path = null; public ?string $test = null; + private array $propDesc = []; + private static array $childPropCache = []; + /** * Hydrate the properties/object with expected data, and handle unexpected data * @@ -65,6 +69,32 @@ public function setProp(string $key, mixed $value): self return $this; } + + /** + * Add description to prop + * + * @param string $key + * @param string $desc + * @return $this + */ + protected function setPropDesc(string $key, string $desc): self + { + $this->propDesc[$key] = $desc; + return $this; + } + + /** + * Get description to prop + * + * @param string $key + * @param string $desc + * @return string + */ + public function getPropDesc(string $key): string + { + return $this->propDesc[$key] ?? ""; + } + /** * Set multiple config props * @@ -100,15 +130,22 @@ public function get(string $key): mixed return ($newKey !== false) ? $this->{$newKey} : null; } - /** - * Return props object as array - * - * @return array - */ - public function toArray(): array - { - return get_object_vars($this); - } + /** + * Return public properties defined on the concrete class + */ + public function toArray(): array + { + $vars = get_object_vars($this); + + if (!isset(self::$childPropCache[static::class])) { + $childDefaults = get_class_vars(static::class); + $baseDefaults = get_class_vars(self::class); + + self::$childPropCache[static::class] = array_diff_key($childDefaults, $baseDefaults); + } + + return array_intersect_key($vars, self::$childPropCache[static::class]); + } /** * Get value as bool value diff --git a/src/AbstractKernel.php b/src/AbstractKernel.php index dd22d41..8d2fc51 100644 --- a/src/AbstractKernel.php +++ b/src/AbstractKernel.php @@ -15,17 +15,20 @@ namespace MaplePHP\Emitron; use MaplePHP\Container\Reflection; +use MaplePHP\Emitron\Contracts\AppInterface; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; use MaplePHP\Emitron\Contracts\EmitterInterface; use MaplePHP\Emitron\Contracts\KernelInterface; use MaplePHP\Emitron\Emitters\CliEmitter; use MaplePHP\Emitron\Emitters\HttpEmitter; +use MaplePHP\Http\Interfaces\PathInterface; use MaplePHP\Http\ResponseFactory; use MaplePHP\Http\Stream; use Psr\Container\ContainerInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\StreamInterface; +use Psr\Http\Server\RequestHandlerInterface; abstract class AbstractKernel implements KernelInterface { @@ -118,6 +121,7 @@ public function getDispatchConfig(): DispatchConfigInterface * * @param ServerRequestInterface $request * @param StreamInterface $stream + * @param RequestHandlerInterface $finalHandler * @param array $middlewares * @return ResponseInterface * @throws \ReflectionException @@ -125,17 +129,33 @@ public function getDispatchConfig(): DispatchConfigInterface protected function initRequestHandler( ServerRequestInterface $request, StreamInterface $stream, + PathInterface $path, + RequestHandlerInterface $finalHandler, array $middlewares = [] - ) : ResponseInterface { - $factory = new ResponseFactory($stream); + ): ResponseInterface { + $this->bindInterfaces([ - "ContainerInterface" => $this->container, "RequestInterface" => $request, - "ServerRequestInterface" => $request, "StreamInterface" => $stream, + "ContainerInterface" => $this->container, + "RequestInterface" => $request, + "ServerRequestInterface" => $request, + "StreamInterface" => $stream, + "PathInterface" => $path ]); + $middlewares = array_merge($this->userMiddlewares, $middlewares); - $handler = new RequestHandler($middlewares, $factory); - $response = $handler->handle($request); - $this->bindInterfaces(["ResponseInterface" => $response]); + $handler = new RequestHandler($middlewares, $finalHandler); + $app = $this->container->has("app") ? $this->container->get("app") : null; + + ob_start(); + $response = $handler->handle($request); + $output = ob_get_clean(); + + if((string)$output !== "" && ($app instanceof AppInterface && !$app->isProd())) { + throw new \RuntimeException( + 'Unexpected output detected during request dispatch. Controllers must write to the response body instead of using echo.' + ); + } + return $response; } diff --git a/src/Configs/ConfigPropsFactory.php b/src/Configs/ConfigPropsFactory.php new file mode 100644 index 0000000..ea7fa64 --- /dev/null +++ b/src/Configs/ConfigPropsFactory.php @@ -0,0 +1,48 @@ +factory->createResponse(); + + $this->appendInterfaces([ + "ResponseInterface" => $response, + ]); + + $controller = $this->controller; + if(is_callable($controller)) { + return $controller($request, $response); + } + + if (!isset($controller[1])) { + $controller[1] = '__invoke'; + } + + if (count($controller) !== 2) { + $response->getBody()->write("ERROR: Invalid controller handler.\n"); + return $response; + } + + [$class, $method] = $controller; + + if (!method_exists($class, $method)) { + $response->getBody()->write("ERROR: Could not load Controller {$class}::{$method}().\n"); + return $response; + } + + // Your DI wiring + $reflect = new Reflection($class); + $classInst = $reflect->dependencyInjector(); + + // This should INVOKE the method and return its result (ResponseInterface or something else) + $result = $reflect->dependencyInjector($classInst, $method); + + return $this->createResponse($response, $result); + } + + + /** + * Will create a PSR valid Response instance form mixed result + * + * @param ResponseInterface $response + * @param mixed $result + * @return ResponseInterface + */ + protected function createResponse(ResponseInterface $response, mixed $result): ResponseInterface + { + if ($result instanceof ResponseInterface) { + return $result; + } + + if($result instanceof StreamInterface) { + return $response->withBody($result); + } + + if(is_array($result) || is_object($result)) { + return $this->createStream($response, json_encode($result, JSON_UNESCAPED_UNICODE)) + ->withHeader("Content-Type", "application/json"); + } + + if(is_string($result) || is_numeric($result)) { + return $this->createStream($response, $result); + } + return $response; + } + + /** + * A helper method to create a new stream instance + * + * @param ResponseInterface $response + * @param mixed $result + * @return ResponseInterface + */ + protected function createStream(ResponseInterface $response, mixed $result): ResponseInterface + { + $streamFactory = new StreamFactory(); + $stream = $streamFactory->createStream($result); + return $response->withBody($stream); + + } + + /** + * Append interface helper method + * + * @param array $bindings + * @return void + */ + protected function appendInterfaces(array $bindings) + { + Reflection::interfaceFactory(function (string $className) use ($bindings) { + return $bindings[$className] ?? null; + }); + } + +} \ No newline at end of file diff --git a/src/DispatchConfig.php b/src/DispatchConfig.php index 256be56..7518216 100644 --- a/src/DispatchConfig.php +++ b/src/DispatchConfig.php @@ -15,29 +15,66 @@ namespace MaplePHP\Emitron; use Exception; +use MaplePHP\Emitron\Configs\ConfigPropsFactory; use MaplePHP\Emitron\Contracts\ConfigPropsInterface; use MaplePHP\Emitron\Contracts\DispatchConfigInterface; -use MaplePHP\Unitary\Config\ConfigProps; -use MaplePHP\Unitary\Interfaces\RouterDispatchInterface; -use MaplePHP\Unitary\Interfaces\RouterInterface; +use MaplePHP\Unitary\Interfaces\RouterDispatchInterface as UnitaryRouterDispatchInterface; +use MaplePHP\Unitary\Interfaces\RouterInterface as UnitaryRouterInterface; +use MaplePHP\Emitron\Contracts\RouterInterface; +use MaplePHP\Emitron\Contracts\RouterDispatchInterface; class DispatchConfig implements DispatchConfigInterface { private string $dir; - private ?RouterInterface $router = null; + private RouterInterface|UnitaryRouterInterface|null $router = null; protected ConfigPropsInterface $props; + protected ?string $configPropClass = null; - /** - * @param string|ConfigPropsInterface|null $props - * @throws Exception - */ - public function __construct(string|null|ConfigPropsInterface $props = null) + /** + * @param string|ConfigPropsInterface|null $props + * @param string|null $configPropClass + * @throws Exception + */ + public function __construct(string|null|ConfigPropsInterface $props = null, ?string $configPropClass = null) { + $this->configPropClass = $configPropClass; if (!($props instanceof ConfigPropsInterface)) { $this->loadConfigFile(($props === null) ? __DIR__ . '/../emitron.config.php' : $props); } } + /** + * Get used config prop class as string + * + * @return string|null + */ + public function getConfigPropsClass(): ?string + { + return $this->configPropClass; + } + + /** + * Get config value + * + * @param string $key + * @return mixed + */ + public function get(string $key): mixed + { + return $this->props->{$key}; + } + + /** + * Check if config prop exists + * + * @param string $key + * @return bool + */ + public function has(string $key): bool + { + return isset($this->props->{$key}); + } + /** * Get instance of ConfigProps * @@ -78,12 +115,12 @@ public function setProps(array $props): self /** * Get current exit code as int or null if not set * - * @return RouterDispatchInterface + * @return UnitaryRouterInterface|RouterDispatchInterface */ - public function getRouter(): RouterDispatchInterface + public function getRouter(): UnitaryRouterInterface|RouterDispatchInterface { if ($this->router === null) { - return new class () implements RouterDispatchInterface { + return new class () implements UnitaryRouterDispatchInterface, RouterDispatchInterface { public function dispatch(callable $call): bool { $call(['handler' => []], [], [], ''); @@ -105,7 +142,7 @@ public function setRouter(callable $call): self { $inst = clone $this; $inst->router = $call($this->dir); - if (!($inst->router instanceof RouterInterface)) { + if (!($inst->router instanceof RouterInterface || $inst->router instanceof UnitaryRouterInterface)) { throw new Exception('Router must implement RouterInterface and "return" a it!'); } return $inst; @@ -131,9 +168,9 @@ public function loadConfigFile(string $path): void if (!is_array($config)) { throw new Exception('The config file do not return a array'); } - + //$this->dir = realpath(dirname($path)); $this->dir = AbstractKernel::getRouterFilePath(); - $this->props = new ConfigProps($config); + $this->props = ConfigPropsFactory::create($config, $this->configPropClass); } } diff --git a/src/Emitters/HttpEmitter.php b/src/Emitters/HttpEmitter.php index b9a0841..54444e2 100644 --- a/src/Emitters/HttpEmitter.php +++ b/src/Emitters/HttpEmitter.php @@ -25,19 +25,20 @@ public function emit(ResponseInterface $response, ServerRequestInterface $reques $body = $response->getBody(); $status = $response->getStatusCode(); $method = strtoupper($request->getMethod()); - $skipBody = in_array($status, [204, 304]) || ($status >= 100 && $status < 200) || $method === 'HEAD'; - - // Default to 204 No Content if nobody was written - if (!$response->getBody()->getSize() && $this->isSuccessfulResponse()) { - $response = $response->withStatus(204); - $response->getBody()->write("No Content\n"); - } + $skipBody = in_array($status, [204, 304], true) + || ($status >= 100 && $status < 200) + || $method === 'HEAD'; + // Create headers $this->createHeaders($response); - if (!$skipBody && $body->isSeekable()) { - $body->rewind(); - } - echo $skipBody ? '' : $body->getContents(); + + // Detach body if HEAD or other detachable status code + if (!$skipBody) { + if ($body->isSeekable()) { + $body->rewind(); + } + echo $body->getContents(); + } } /** diff --git a/src/Enums/DispatchCodes.php b/src/Enums/DispatchCodes.php new file mode 100644 index 0000000..f16715c --- /dev/null +++ b/src/Enums/DispatchCodes.php @@ -0,0 +1,12 @@ +dispatchConfig->getRouter()->dispatch(function ($data, $args, $middlewares) use ($request, $stream) { - if (!isset($data['handler'])) { - throw new InvalidArgumentException("The router dispatch method arg 1 is missing the 'handler' key."); - } + + $parts = isset($data[2]) && is_array($data[2]) ? $data[2] : []; + $dispatchCode = (int)($data[0] ?? DispatchCodes::FOUND->value); + + + if($dispatchCode !== DispatchCodes::FOUND->value) { + $data['handler'] = function (ServerRequestInterface $req, ResponseInterface $res): ResponseInterface + { + return $res->withStatus(404); + }; + } + //$dispatchCode = $data[0] ?? RouterDispatcher::FOUND; + [$data, $args, $middlewares] = $this->reMap($data, $args, $middlewares); $this->container->set("request", $request); $this->container->set("args", $args); $this->container->set("configuration", $this->getDispatchConfig()); - $response = $this->initRequestHandler($request, $this->getBody($stream), $middlewares); - - $controller = $data['handler']; - if (!isset($controller[1])) { - $controller[1] = '__invoke'; - } - if (count($controller) === 2) { - [$class, $method] = $controller; - if (method_exists($class, $method)) { - $reflect = new Reflection($class); - $classInst = $reflect->dependencyInjector(); - // Can replace the active Response instance through Command instance - $hasNewResponse = $reflect->dependencyInjector($classInst, $method); - $response = ($hasNewResponse instanceof ResponseInterface) ? $hasNewResponse : $response; - - } else { - $response->getBody()->write("\nERROR: Could not load Controller class {$class} and method {$method}()\n"); - } - } + $bodyStream = $this->getBody($stream); + $factory = new ResponseFactory($bodyStream); + $finalHandler = new ControllerRequestHandler($factory, $data['handler'] ?? []); + $path = new Path($parts, $request); + + + $response = $this->initRequestHandler( + request: $request, + stream: $bodyStream, + path: $path, + finalHandler: $finalHandler, + middlewares: $middlewares + ); $this->createEmitter()->emit($response, $request); }); } + + + function reMap($data, $args, $middlewares) + { + if (isset($data[1]) && $middlewares instanceof ServerRequestInterface) { + $item = $data[1]; + return [ + ["handler" => $item['controller']], $_REQUEST, ($item['data'] ?? []) + ]; + } + if (!is_array($middlewares)) { + $middlewares = []; + } + + if (!is_array($args)) { + $args = []; + } + return [$data, $args, $middlewares]; + } } \ No newline at end of file diff --git a/src/Middlewares/HeadRequestMiddleware.php b/src/Middlewares/HeadRequestMiddleware.php index c0ad931..80ff769 100644 --- a/src/Middlewares/HeadRequestMiddleware.php +++ b/src/Middlewares/HeadRequestMiddleware.php @@ -4,6 +4,7 @@ namespace MaplePHP\Emitron\Middlewares; +use MaplePHP\Http\StreamFactory; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Message\ResponseInterface; @@ -12,27 +13,19 @@ class HeadRequestMiddleware implements MiddlewareInterface { /** - * Set cache control if it does not exist - * - * Note: Clearing cache on dynamic content is a good standard to make sure that no - * sensitive content will be cached. + * Detach body on HEAD * * @param ServerRequestInterface $request * @param RequestHandlerInterface $handler * @return ResponseInterface */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $response = $handler->handle($request); - - if (strtoupper($request->getMethod()) === 'HEAD') { - $body = $response->getBody(); - if ($body->isWritable() && $body->isSeekable()) { - $body->rewind(); - $body->write(''); - } - } - - return $response; - } + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $response = $handler->handle($request); + if (strtoupper($request->getMethod()) !== 'HEAD') { + return $response; + } + $streamFactory = new StreamFactory(); + return $response->withBody($streamFactory->createStream()); + } } diff --git a/src/Middlewares/OutputMiddleware.php b/src/Middlewares/OutputMiddleware.php deleted file mode 100644 index d2e7d4a..0000000 --- a/src/Middlewares/OutputMiddleware.php +++ /dev/null @@ -1,78 +0,0 @@ -stream = $stream; - } - /** - * Get the body content length reliably with PSR Stream. - * - * @param ServerRequestInterface $request - * @param RequestHandlerInterface $handler - * @return ResponseInterface - */ - public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - - if ($this->stream === null) { - $this->stream = new Stream(Stream::TEMP); - } - - $response = $handler->handle($request); - $response = $response->withBody($this->stream); - return $response; - } - - - /** - * Will add "Accept-Encoding" into the "Vary" header - * - * Note: Ensures caches serve the correct version (compressed or not) based on the - * client's "Accept-Encoding" header. Prevents issues with shared caches. - * - * @param ResponseInterface $response - * @return ResponseInterface - */ - private function addAcceptEncodingToVary(ResponseInterface $response): ResponseInterface - { - if ($response->hasHeader('Vary')) { - $existing = $response->getHeaderLine('Vary'); - if (!str_contains($existing, 'Accept-Encoding')) { - $response = $response->withHeader('Vary', $existing . ', Accept-Encoding'); - } - } else { - $response = $response->withHeader('Vary', 'Accept-Encoding'); - } - - return $response; - } - - /** - * Check if gzip is applicable - * - * @param ServerRequestInterface $request - * @param ResponseInterface $response - * @return bool - */ - private function canUseGzip(ServerRequestInterface $request, ResponseInterface $response): bool - { - $acceptEncoding = $request->getHeaderLine('Accept-Encoding'); - $hasGzip = function_exists('gzencode') && str_contains($acceptEncoding, 'gzip'); - return $hasGzip && !$response->hasHeader('Content-Encoding') && $response->getBody()->isSeekable(); - } -} diff --git a/src/RequestHandler.php b/src/RequestHandler.php index cbdb0e7..7e5b82a 100644 --- a/src/RequestHandler.php +++ b/src/RequestHandler.php @@ -1,37 +1,35 @@ */ private array $middlewareQueue; - private ResponseFactoryInterface $responseSource; - public function __construct(array $middlewares, ResponseFactoryInterface $responseFactory) - { + public function __construct( + array $middlewares, + private readonly RequestHandlerInterface $finalHandler + ) { $this->middlewareQueue = $middlewares; - $this->responseSource = $responseFactory; } - /** - * Process middlewares and middleware that is a string class then it will use the - * dependency injector in the constructor. - * - * @param ServerRequestInterface $request - * @return ResponseInterface - * @throws \ReflectionException - */ public function handle(ServerRequestInterface $request): ResponseInterface { + + // End of chain -> call controller (or whatever final handler you set) if (!isset($this->middlewareQueue[$this->index])) { - return $this->responseSource->createResponse(200); + return $this->finalHandler->handle($request); } $middleware = $this->middlewareQueue[$this->index]; @@ -41,6 +39,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface $reflect = new Reflection($middleware); $middleware = $reflect->dependencyInjector(); } + return $middleware->process($request, $this); } } \ No newline at end of file diff --git a/tests/unitary-emitron.php b/tests/unitary-emitron.php index 49bb66e..8c42b49 100755 --- a/tests/unitary-emitron.php +++ b/tests/unitary-emitron.php @@ -1,10 +1,10 @@ withName("emitron"); group($config->withSubject("Testing middleware and emitter"), function (TestCase $case) { - $stream = new Stream(Stream::TEMP); - $stream->write("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras eleifend ligula vel diam tincidunt finibus. In dapibus dictum lectus a malesuada."); + + // It will reverse the order $middlewares = [ new ContentLengthMiddleware(), new GzipMiddleware(), - new OutputMiddleware($stream), new HeadRequestMiddleware(), ]; @@ -31,24 +32,29 @@ $uri = new Uri($env->getUriParts()); $request = new ServerRequest($uri, $env); - // Do not exist in CLI so need to mock it + // This is something that is usually set by the browser + // So this does not exist in CLI so need to mock it $request = $request->withHeader("Accept-Encoding", "gzip"); - $factory = new ResponseFactory(); - $factory->createResponse(); - $handler = new RequestHandler($middlewares, $factory); - $response = $handler->handle($request); + $stream = new Stream(Stream::TEMP); + $factory = new ResponseFactory($stream); + $factory->createResponse(200, "OK"); + $finalHandler = new ControllerRequestHandler($factory, function(RequestInterface $req, ResponseInterface $res) { + $res->getBody()->write("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras eleifend ligula vel diam tincidunt finibus. In dapibus dictum lectus a malesuada."); + return $res; + }); + $handler = new RequestHandler($middlewares, $finalHandler); + $response = $handler->handle($request); $emit = new HttpEmitter(); ob_start(); - $emit->emit($response->withBody($stream), $request); + $emit->emit($response, $request); $out = ob_get_clean(); - $case->validate($out, function (Expect $expect) { - $expect->isLength(143); + $expect->isLength(119); }); $headers = $response->getHeaders();