Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e8eb906
Introduce `Responde` bridge implementation with tests.
terabytesoftw Jul 21, 2025
00eea09
Apply fixes from StyleCI
StyleCIBot Jul 21, 2025
2f86130
refactor(Response): Change base class from Yii\web\Response to BaseRe…
terabytesoftw Jul 21, 2025
fb976b7
feat(composer): Add psr-http-factory requirement for improved PSR-7 c…
terabytesoftw Jul 21, 2025
9c4fac4
fix(composer): Correct typo in psr/http-factory requirement,
terabytesoftw Jul 21, 2025
d3bb0fb
refactor(Response): Enhance session cookie handling with additional p…
terabytesoftw Jul 21, 2025
cb1fd90
fix(Response): Ensure default values for session cookie parameters.
terabytesoftw Jul 21, 2025
e699fea
fix(Response): Refactor session cookie handling to use consistent var…
terabytesoftw Jul 21, 2025
ca33321
fix(Response): Improve session handling by ensuring response is sent …
terabytesoftw Jul 21, 2025
1338770
fix(Response): Correct indentation for session closure to improve cod…
terabytesoftw Jul 21, 2025
215abbc
refactor(Response): Remove unnecessary alias for base Response class …
terabytesoftw Jul 21, 2025
f8a5170
Add tests for `ResponseAdapter` class.
terabytesoftw Jul 21, 2025
1cc58fc
Apply fixes from StyleCI
StyleCIBot Jul 21, 2025
31eb1b6
fix(ResponseAdapter): Improve cookie header formatting for consistenc…
terabytesoftw Jul 21, 2025
b48ad53
fix(ResponseAdapter): Skip adding cookies with empty values to improv…
terabytesoftw Jul 21, 2025
54d1d28
fix(ResponseAdapter): Enhance cookie header handling by improving val…
terabytesoftw Jul 22, 2025
3aaf063
fix(ResponseAdapter): Use standardized error message for missing cook…
terabytesoftw Jul 22, 2025
0c3a689
fix(ResponseAdapter): Enhance cookie validation logic to ensure expir…
terabytesoftw Jul 22, 2025
64153e2
fix(ResponseAdapter): Simplify cookie validation logic by removing re…
terabytesoftw Jul 22, 2025
92e2946
test(ResponseAdapter): Add tests for cookie hashing and expiration ha…
terabytesoftw Jul 22, 2025
2d9f72e
test(ResponseAdapter): Implement comprehensive tests for cookie handl…
terabytesoftw Jul 22, 2025
e1b83e1
Apply fixes from StyleCI
StyleCIBot Jul 22, 2025
a4b0e90
test(ResponseAdapter): Add test for cookie validation with future exp…
terabytesoftw Jul 22, 2025
65b21f1
test(ResponseTest): Add comprehensive tests for response handling wit…
terabytesoftw Jul 22, 2025
6ee7f0b
fix(TestCase): Ensure session is closed only if it exists in `tearDow…
terabytesoftw Jul 22, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .styleci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ enabled:
- declare_strict_types
- dir_constant
- empty_loop_body_braces
- fully_qualified_strict_types
- function_to_constant
- hash_to_slash_comment
- integer_literal_case
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"require": {
"php": ">=8.1",
"psr/http-message": "^2.0",
"psr/http-factory": "^1.0",
"yiisoft/yii2": "^2.0.53|^22"
},
"require-dev": {
Expand Down
133 changes: 133 additions & 0 deletions src/adapter/ResponseAdapter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

declare(strict_types=1);

namespace yii2\extensions\psrbridge\adapter;

use DateTimeInterface;
use Psr\Http\Message\{ResponseFactoryInterface, ResponseInterface, StreamFactoryInterface};
use Yii;
use yii\base\InvalidConfigException;
use yii\helpers\Json;
use yii\web\{Cookie, Response};
use yii2\extensions\psrbridge\exception\Message;

use function gmdate;
use function max;
use function time;
use function urlencode;

final class ResponseAdapter
{
public function __construct(
private Response $response,
private ResponseFactoryInterface $responseFactory,
private StreamFactoryInterface $streamFactory,
) {}

public function toPsr7(): ResponseInterface
{
// Create base response
$psr7Response = $this->responseFactory->createResponse(
$this->response->getStatusCode(),
$this->response->statusText,
);

// Add headers
foreach ($this->response->getHeaders() as $name => $values) {
// @phpstan-ignore-next-line
$psr7Response = $psr7Response->withHeader($name, $values);
}

// Add cookies with proper formatting
foreach ($this->buildCookieHeaders() as $cookieHeader) {
$psr7Response = $psr7Response->withAddedHeader('Set-Cookie', $cookieHeader);
}

// Add body
$body = $this->streamFactory->createStream($this->response->content ?? '');
return $psr7Response->withBody($body);
}

/**
* Build cookie headers with proper formatting and validation
*
* @phpstan-return string[] Array of formatted cookie headers.
*/
private function buildCookieHeaders(): array
{
$headers = [];
$request = Yii::$app->getRequest();

// Check if cookie validation is enabled
$enableValidation = $request->enableCookieValidation;
$validationKey = null;

if ($enableValidation) {
$validationKey = $request->cookieValidationKey;

if ($validationKey === '') {
throw new InvalidConfigException(
Message::COOKIE_VALIDATION_KEY_NOT_CONFIGURED->getMessage($request::class),
);
}
}

foreach ($this->response->getCookies() as $cookie) {
// Skip cookies with empty values
if ($cookie->value !== null && $cookie->value !== '') {
$headers[] = $this->formatCookieHeader($cookie, $enableValidation, $validationKey);
}
}

return $headers;
}

private function formatCookieHeader(Cookie $cookie, bool $enableValidation, string|null $validationKey): string
{
$value = $cookie->value;
$expire = $cookie->expire;

if (is_numeric($expire)) {
$expire = (int) $expire;
}

if (is_string($expire)) {
$expire = (int) strtotime($expire);
}

if ($expire instanceof DateTimeInterface) {
$expire = $expire->getTimestamp();
}

if ($enableValidation && $validationKey !== null && ($expire === 0 || $expire >= time())) {
$value = Yii::$app->getSecurity()->hashData(Json::encode([$cookie->name, $cookie->value]), $validationKey);
}

$header = urlencode($cookie->name) . '=' . urlencode($value);

if ($expire !== null && $expire !== 0) {
$expires = gmdate('D, d-M-Y H:i:s T', $expire);
$maxAge = max(0, $expire - time());

$header .= "; Expires={$expires}";
$header .= "; Max-Age={$maxAge}";
}

$attributes = [
'Path' => $cookie->path !== '' ? $cookie->path : null,
'Domain' => $cookie->domain !== '' ? $cookie->domain : null,
'Secure' => $cookie->secure ? 'Secure' : null,
'HttpOnly' => $cookie->httpOnly ? '' : null,
'SameSite' => $cookie->sameSite !== null ? $cookie->sameSite : null,
];

foreach ($attributes as $key => $val) {
if ($val !== null) {
$header .= "; $key" . ($val !== '' ? "=$val" : '');
}
}

return $header;
}
}
7 changes: 7 additions & 0 deletions src/exception/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ enum Message: string
*/
case BUFFER_LENGTH_INVALID = 'Buffer length for `%s` must be greater than zero; received `%d`.';

/**
* Error when the cookie validation key is not configured for a specific class.
*
* Format: "%s::cookieValidationKey must be configured with a secret key."
*/
case COOKIE_VALIDATION_KEY_NOT_CONFIGURED = '%s::cookieValidationKey must be configured with a secret key.';

/**
* Error when the cookie validation key is missing.
*
Expand Down
65 changes: 65 additions & 0 deletions src/http/Response.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

declare(strict_types=1);

namespace yii2\extensions\psrbridge\http;

use Psr\Http\Message\{ResponseFactoryInterface, ResponseInterface, StreamFactoryInterface};
use Yii;
use yii\web\Cookie;
use yii2\extensions\psrbridge\adapter\ResponseAdapter;

final class Response extends \yii\web\Response
{
public function getPsr7Response(): ResponseInterface
{
$adapter = new ResponseAdapter(
$this,
Yii::$container->get(ResponseFactoryInterface::class),
Yii::$container->get(StreamFactoryInterface::class),
);

$this->trigger(self::EVENT_BEFORE_SEND);
$this->prepare();

Check warning on line 23 in src/http/Response.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ { $adapter = new ResponseAdapter($this, Yii::$container->get(ResponseFactoryInterface::class), Yii::$container->get(StreamFactoryInterface::class)); $this->trigger(self::EVENT_BEFORE_SEND); - $this->prepare(); + $this->trigger(self::EVENT_AFTER_PREPARE); if (Yii::$app->has('session') === false) { $response = $adapter->toPsr7();

Check warning on line 23 in src/http/Response.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ { $adapter = new ResponseAdapter($this, Yii::$container->get(ResponseFactoryInterface::class), Yii::$container->get(StreamFactoryInterface::class)); $this->trigger(self::EVENT_BEFORE_SEND); - $this->prepare(); + $this->trigger(self::EVENT_AFTER_PREPARE); if (Yii::$app->has('session') === false) { $response = $adapter->toPsr7();
$this->trigger(self::EVENT_AFTER_PREPARE);

if (Yii::$app->has('session') === false) {
$response = $adapter->toPsr7();

$this->trigger(self::EVENT_AFTER_SEND);

$this->isSent = true;

return $response;
}

$session = Yii::$app->getSession();
$cookieParams = $session->getCookieParams();

if ($session->getIsActive()) {
$this->cookies->add(
new Cookie(
[
'name' => $session->getName(),
'value' => $session->getId(),
'path' => $cookieParams['path'] ?? '/',
'domain' => $cookieParams['domain'] ?? '',
'secure' => $cookieParams['secure'] ?? false,

Check warning on line 47 in src/http/Response.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "FalseValue": @@ @@ $session = Yii::$app->getSession(); $cookieParams = $session->getCookieParams(); if ($session->getIsActive()) { - $this->cookies->add(new Cookie(['name' => $session->getName(), 'value' => $session->getId(), 'path' => $cookieParams['path'] ?? '/', 'domain' => $cookieParams['domain'] ?? '', 'secure' => $cookieParams['secure'] ?? false, 'httpOnly' => $cookieParams['httponly'] ?? true, 'sameSite' => $cookieParams['samesite'] ?? null])); + $this->cookies->add(new Cookie(['name' => $session->getName(), 'value' => $session->getId(), 'path' => $cookieParams['path'] ?? '/', 'domain' => $cookieParams['domain'] ?? '', 'secure' => $cookieParams['secure'] ?? true, 'httpOnly' => $cookieParams['httponly'] ?? true, 'sameSite' => $cookieParams['samesite'] ?? null])); $session->close(); } $response = $adapter->toPsr7();

Check warning on line 47 in src/http/Response.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "FalseValue": @@ @@ $session = Yii::$app->getSession(); $cookieParams = $session->getCookieParams(); if ($session->getIsActive()) { - $this->cookies->add(new Cookie(['name' => $session->getName(), 'value' => $session->getId(), 'path' => $cookieParams['path'] ?? '/', 'domain' => $cookieParams['domain'] ?? '', 'secure' => $cookieParams['secure'] ?? false, 'httpOnly' => $cookieParams['httponly'] ?? true, 'sameSite' => $cookieParams['samesite'] ?? null])); + $this->cookies->add(new Cookie(['name' => $session->getName(), 'value' => $session->getId(), 'path' => $cookieParams['path'] ?? '/', 'domain' => $cookieParams['domain'] ?? '', 'secure' => $cookieParams['secure'] ?? true, 'httpOnly' => $cookieParams['httponly'] ?? true, 'sameSite' => $cookieParams['samesite'] ?? null])); $session->close(); } $response = $adapter->toPsr7();
'httpOnly' => $cookieParams['httponly'] ?? true,

Check warning on line 48 in src/http/Response.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "TrueValue": @@ @@ $session = Yii::$app->getSession(); $cookieParams = $session->getCookieParams(); if ($session->getIsActive()) { - $this->cookies->add(new Cookie(['name' => $session->getName(), 'value' => $session->getId(), 'path' => $cookieParams['path'] ?? '/', 'domain' => $cookieParams['domain'] ?? '', 'secure' => $cookieParams['secure'] ?? false, 'httpOnly' => $cookieParams['httponly'] ?? true, 'sameSite' => $cookieParams['samesite'] ?? null])); + $this->cookies->add(new Cookie(['name' => $session->getName(), 'value' => $session->getId(), 'path' => $cookieParams['path'] ?? '/', 'domain' => $cookieParams['domain'] ?? '', 'secure' => $cookieParams['secure'] ?? false, 'httpOnly' => $cookieParams['httponly'] ?? false, 'sameSite' => $cookieParams['samesite'] ?? null])); $session->close(); } $response = $adapter->toPsr7();

Check warning on line 48 in src/http/Response.php

View workflow job for this annotation

GitHub Actions / mutation / PHP 8.4-ubuntu-latest

Escaped Mutant for Mutator "TrueValue": @@ @@ $session = Yii::$app->getSession(); $cookieParams = $session->getCookieParams(); if ($session->getIsActive()) { - $this->cookies->add(new Cookie(['name' => $session->getName(), 'value' => $session->getId(), 'path' => $cookieParams['path'] ?? '/', 'domain' => $cookieParams['domain'] ?? '', 'secure' => $cookieParams['secure'] ?? false, 'httpOnly' => $cookieParams['httponly'] ?? true, 'sameSite' => $cookieParams['samesite'] ?? null])); + $this->cookies->add(new Cookie(['name' => $session->getName(), 'value' => $session->getId(), 'path' => $cookieParams['path'] ?? '/', 'domain' => $cookieParams['domain'] ?? '', 'secure' => $cookieParams['secure'] ?? false, 'httpOnly' => $cookieParams['httponly'] ?? false, 'sameSite' => $cookieParams['samesite'] ?? null])); $session->close(); } $response = $adapter->toPsr7();
'sameSite' => $cookieParams['samesite'] ?? null,
],
),
);

$session->close();
}

$response = $adapter->toPsr7();

$this->trigger(self::EVENT_AFTER_SEND);

$this->isSent = true;

return $response;
}
}
6 changes: 6 additions & 0 deletions tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,14 @@ public static function tearDownAfterClass(): void
{
parent::tearDownAfterClass();

// Ensure the logger is flushed after all tests
$logger = Yii::getLogger();
$logger->flush();

// Close the session if it was started
if (Yii::$app->has('session')) {
Yii::$app->getSession()->close();
}
}

protected function setUp(): void
Expand Down
Loading