Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions src/router/src/Contracts/UrlGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ public function forceScheme(?string $scheme): void;
*/
public function forceHttps(bool $force = true): void;

/**
* Set the URL origin for all generated URLs.
*/
public function useOrigin(?string $root): void;

/**
* Set a callback to be used to format the host of generated URLs.
*/
Expand Down
27 changes: 27 additions & 0 deletions src/router/src/UrlGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,23 @@ public function forceHttps(bool $force = true): void
}
}

/**
* Set the URL origin for all generated URLs.
*
* This is stored in coroutine Context for request isolation.
*/
public function useOrigin(?string $root): void
{
if ($root !== null) {
Context::set('__url.forced_root', rtrim($root, '/'));
} else {
Context::destroy('__url.forced_root');
}

// Clear the cached root so it will be recalculated
Context::destroy('__request.root.uri');
}

/**
* Set a callback to be used to format the host of generated URLs.
*/
Expand Down Expand Up @@ -469,6 +486,16 @@ protected function getSignedKey(): string

protected function getRootUrl(string $scheme): string
{
// Check for forced root first
$forcedRoot = Context::get('__url.forced_root');
if ($forcedRoot !== null) {
$root = new Uri($forcedRoot);

return $root->withScheme(
str_replace('://', '', $scheme)
)->toString();
}

$root = Context::getOrSet('__request.root.uri', function () {
$requestUri = $this->getRequestUri()->toString();
$root = preg_replace(';^([^:]+://[^/?#]+).*$;', '\1', $requestUri);
Expand Down
84 changes: 84 additions & 0 deletions tests/Router/UrlGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ protected function tearDown(): void
parent::tearDown();

Context::destroy('__request.root.uri');
Context::destroy('__url.forced_root');
Context::destroy(ServerRequestInterface::class);
}

Expand Down Expand Up @@ -554,6 +555,89 @@ public function testSignedUrlParameterCannotBeNamedExpires()
Request::create($urlGenerator->signedRoute('foo', ['expires' => 253402300799]));
}

public function testUseOrigin()
{
$this->mockRequest();

$urlGenerator = new UrlGenerator($this->container);

// Test original URL
$this->assertEquals('http://example.com/foo', $urlGenerator->to('foo'));

// Test forcing root URL
$urlGenerator->useOrigin('https://tenant.example.com');
$this->assertEquals('http://tenant.example.com/foo', $urlGenerator->to('foo'));

// Test with secure
$this->assertEquals('https://tenant.example.com/foo', $urlGenerator->to('foo', [], true));

// Test forcing different root
$urlGenerator->useOrigin('https://other-tenant.example.com');
$this->assertEquals('http://other-tenant.example.com/bar', $urlGenerator->to('bar'));

// Test clearing forced root (passing null)
$urlGenerator->useOrigin(null);
$this->assertEquals('http://example.com/baz', $urlGenerator->to('baz'));
}

public function testUseOriginWithTrailingSlash()
{
$this->mockRequest();

$urlGenerator = new UrlGenerator($this->container);

// Test forcing root URL with trailing slash (should be trimmed)
$urlGenerator->useOrigin('https://tenant.example.com/');
$this->assertEquals('http://tenant.example.com/foo', $urlGenerator->to('foo'));
}

public function testUseOriginWithSchemeOverride()
{
$this->mockRequest();

$urlGenerator = new UrlGenerator($this->container);

// Force root with https, but use http scheme in to()
$urlGenerator->useOrigin('https://tenant.example.com');
$this->assertEquals('http://tenant.example.com/foo', $urlGenerator->to('foo', [], false));

// Force root with http, but use https scheme in to()
$urlGenerator->useOrigin('http://tenant.example.com');
$this->assertEquals('https://tenant.example.com/foo', $urlGenerator->to('foo', [], true));
}

public function testUseOriginAffectsAsset()
{
$this->mockRequest();

$urlGenerator = new UrlGenerator($this->container);

// Test original asset URL
$this->assertEquals('http://example.com/css/app.css', $urlGenerator->asset('css/app.css'));

// Test forcing root URL affects asset generation
$urlGenerator->useOrigin('https://tenant.example.com');
$this->assertEquals('http://tenant.example.com/css/app.css', $urlGenerator->asset('css/app.css'));
}

public function testUseOriginClearsCache()
{
$this->mockRequest();

$urlGenerator = new UrlGenerator($this->container);

// Generate a URL to populate the cache
$this->assertEquals('http://example.com/foo', $urlGenerator->to('foo'));
$this->assertNotNull(Context::get('__request.root.uri'));

// useOrigin should clear the cache
$urlGenerator->useOrigin('https://tenant.example.com');
$this->assertNull(Context::get('__request.root.uri'));

// New URL should use forced root
$this->assertEquals('http://tenant.example.com/bar', $urlGenerator->to('bar'));
}

private function mockContainer()
{
/** @var ContainerInterface|MockInterface */
Expand Down