Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[1.x] Fixes route() base uri, domain, and query parameters #88

Merged
merged 10 commits into from
Aug 16, 2023
2 changes: 1 addition & 1 deletion src/Console/ListCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ protected function routesFromMountPaths(array $mountPaths): Collection
protected function routeName(string $mountPath, string $viewPath): ?string
{
return collect($this->laravel->make(FolioRoutes::class)->routes())->search(function (array $route) use ($mountPath, $viewPath) {
[$routeRelativeMountPath, $routeRelativeViewPath] = $route;
['mountPath' => $routeRelativeMountPath, 'path' => $routeRelativeViewPath] = $route;

return $routeRelativeMountPath === Project::relativePathOf($mountPath)
&& $routeRelativeViewPath === Project::relativePathOf($viewPath);
Expand Down
79 changes: 61 additions & 18 deletions src/FolioRoutes.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@

class FolioRoutes
{
/**
* The current version of the persisted route cache.
*/
protected static int $version = 1;

/**
* Create a new Folio routes instance.
*
Expand All @@ -41,7 +46,10 @@ public function persist(): void

File::put(
$this->cachedFolioRoutesPath,
'<?php return '.var_export($this->routes, true).';',
'<?php return '.var_export([
'version' => static::$version,
'routes' => $this->routes,
], true).';',
);
}

Expand All @@ -67,9 +75,15 @@ protected function load(): void
}

if (File::exists($this->cachedFolioRoutesPath)) {
$this->routes = File::getRequire($this->cachedFolioRoutesPath);
$cache = File::getRequire($this->cachedFolioRoutesPath);

return;
if (isset($cache['version']) && (int) $cache['version'] === static::$version) {
$this->routes = $cache['routes'];

$this->loaded = true;

return;
}
}

foreach ($this->manager->mountPaths() as $mountPath) {
Expand All @@ -80,8 +94,10 @@ protected function load(): void

if ($name = $matchedView->name()) {
$this->routes[$name] = [
Project::relativePathOf($matchedView->mountPath),
Project::relativePathOf($matchedView->path),
'mountPath' => Project::relativePathOf($matchedView->mountPath),
'path' => Project::relativePathOf($matchedView->path),
'baseUri' => $mountPath->baseUri,
'domain' => $mountPath->domain,
];
}
}
Expand All @@ -102,6 +118,8 @@ public function has(string $name): bool

/**
* Get the route URL for the given route name and arguments.
*
* @thows \Laravel\Folio\Exceptions\UrlGenerationException
*/
public function get(string $name, array $arguments, bool $absolute): string
{
Expand All @@ -111,50 +129,75 @@ public function get(string $name, array $arguments, bool $absolute): string
throw new RouteNotFoundException("Route [{$name}] not found.");
}

[$mountPath, $path] = $this->routes[$name];
[
'mountPath' => $mountPath,
'path' => $path,
'baseUri' => $baseUri,
'domain' => $domain,
] = $this->routes[$name];

return with($this->path($mountPath, $path, $arguments), function (string $path) use ($absolute) {
return $absolute ? url($path) : $path;
});
[$path, $remainingArguments] = $this->path($mountPath, $path, $arguments);

$route = new Route(['GET'], '{__folio_path}', fn () => null);

$route->name($name)->domain($domain);

$uri = $baseUri === '/' ? $path : $baseUri.$path;

try {
return url()->toRoute($route, [...$remainingArguments, '__folio_path' => $uri], $absolute);
} catch (\Illuminate\Routing\Exceptions\UrlGenerationException $e) {
throw new UrlGenerationException(str_replace('{__folio_path}', $uri, $e->getMessage()), $e->getCode(), $e);
}
}

/**
* Get the relative route URL for the given route name and arguments.
*
* @param array<string, mixed> $parameters
* @return array{string, array<string, mixed>}
*/
protected function path(string $mountPath, string $path, array $parameters): string
protected function path(string $mountPath, string $path, array $parameters): array
{
$uri = str_replace('.blade.php', '', $path);

[$parameters, $usedParameters] = [collect($parameters), collect()];

$uri = collect(explode('/', $uri))
->map(function (string $segment) use ($parameters, $uri) {
->map(function (string $segment) use ($parameters, $uri, $usedParameters) {
if (! Str::startsWith($segment, '[')) {
return $segment;
}

$segment = new PotentiallyBindablePathSegment($segment);

$parameters = collect($parameters)->mapWithKeys(function (mixed $value, string $key) {
return [Str::camel($key) => $value];
})->all();
$name = $segment->variable();

if (! isset($parameters[$name = $segment->variable()]) || $parameters[$name] === null) {
$key = $parameters->search(function (mixed $value, string $key) use ($name) {
return Str::camel($key) === $name && $value !== null;
});

if ($key === false) {
throw UrlGenerationException::forMissingParameter($uri, $name);
}

$usedParameters->add($key);

return $this->formatParameter(
$uri,
$name,
$parameters[$name],
Str::camel($key),
$parameters->get($key),
$segment->field(),
$segment->capturesMultipleSegments()
);
})->implode('/');

$uri = str_replace(['/index', '/index/'], ['', '/'], $uri);

return '/'.ltrim(substr($uri, strlen($mountPath)), '/');
return [
'/'.ltrim(substr($uri, strlen($mountPath)), '/'),
$parameters->except($usedParameters->all())->all(),
];
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/Options/PageOptions.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Laravel\Folio\Options;

use Closure;

use function Laravel\Folio\middleware;
use function Laravel\Folio\name;
use function Laravel\Folio\withTrashed;
Expand Down
20 changes: 20 additions & 0 deletions tests/Feature/NameTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,23 @@
test('routes may not have a name', function () {
route('users.index');
})->throws(RouteNotFoundException::class, 'Route [users.index] not defined.');

test('custom uri', function () {
Folio::uri('/user')->path(__DIR__.'/resources/views/even-more-pages');

$absoluteRoute = route('profile');
$route = route('profile', [], false);

expect($absoluteRoute)->toBe('http://localhost/user/profile')
->and($route)->toBe('/user/profile');
});

test('custom domain', function () {
Folio::domain('example.com')->uri('/user')->path(__DIR__.'/resources/views/even-more-pages');

$absoluteRoute = route('profile');
$route = route('profile', [], false);

expect($absoluteRoute)->toBe('http://example.com/user/profile')
->and($route)->toBe('/user/profile');
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
<div>
My profile
</div>

<?php

Laravel\Folio\name('profile');
120 changes: 108 additions & 12 deletions tests/Unit/FolioRoutesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,21 @@
$arguments = collect($arguments)->map(fn ($argument) => value($argument))->all();

$names = new FolioRoutes(Mockery::mock(FolioManager::class), '', [
$name => [$mountPath, $viewPath],
$name => [
'mountPath' => $mountPath,
'path' => $viewPath,
'baseUri' => '/',
'domain' => null,
],
], true);

expect($names->has($name))->toBeTrue()
->and($names->get($name, $arguments, false))->toBe($expectedRoute);
})->with(fn () => collect([
'podcasts.index' => ['podcasts/index.blade.php', [], '/podcasts'],
'podcasts.index-with-query-parameters' => ['podcasts/index.blade.php', ['page' => 1], '/podcasts?page=1'],
'podcasts.show-by-id' => ['podcasts/[id].blade.php', ['id' => 1], '/podcasts/1'],
'podcasts.show-by-name' => ['podcasts/[name].blade.php', ['name' => 'Taylor'], '/podcasts/Taylor'],
'podcasts.show-by-name' => ['podcasts/[name].blade.php', ['Name' => 'Taylor'], '/podcasts/Taylor'],
'podcasts.show-by-slug' => ['podcasts/[slug].blade.php', ['slug' => 'nuno'], '/podcasts/nuno'],
'podcasts.show-by-slug-and-id' => ['podcasts/[slug]/[id].blade.php', ['slug' => 'nuno', 'id' => 1], '/podcasts/nuno/1'],
'podcasts.show-by-model' => ['podcasts/[Podcast].blade.php', ['podcast' => fn () => Podcast::first()], '/podcasts/1'],
Expand All @@ -49,16 +55,20 @@
'podcasts.show-by-backed-enum' => ['podcasts/[Category].blade.php', ['category' => Category::Post], '/podcasts/posts'],
'podcasts.show-by-id-with-nested-page' => ['podcasts/[id]/stats.blade.php', ['id' => 1], '/podcasts/1/stats'],
'podcasts.stats' => ['podcasts/stats.blade.php', [], '/podcasts/stats'],
'podcasts.stats-with-query-parameters' => ['podcasts/stats.blade.php', ['Page' => 1, 'lowerCase' => 'lowerCaseKeyValue', 'Upper_case-key' => 'Upper_caseKeyValue'], '/podcasts/stats?Page=1&lowerCase=lowerCaseKeyValue&Upper_case-key=Upper_caseKeyValue'],
'podcasts.many-by-id' => ['podcasts/[...id].blade.php', ['ids' => [1, 2, 3]], '/podcasts/1/2/3'],
'podcasts.many-by-name' => ['podcasts/[...name].blade.php', ['names' => ['Taylor', 'Nuno']], '/podcasts/Taylor/Nuno'],
'podcasts.many-by-slug' => ['podcasts/[...slug].blade.php', ['slugs' => ['nuno', 'taylor']], '/podcasts/nuno/taylor'],
'podcasts.many-by-slug-and-id' => ['podcasts/[...slug]/[...id].blade.php', ['slugs' => ['nuno', 'taylor'], 'ids' => [1, 2]], '/podcasts/nuno/taylor/1/2'],
'podcasts.many-by-slug-and-id' => ['podcasts/[...slug]/[...id].blade.php', ['Slugs' => ['nuno', 'taylor'], 'ids' => [1, 2]], '/podcasts/nuno/taylor/1/2'],
'podcasts.many-by-model' => ['podcasts/[...Podcast].blade.php', ['podcasts' => fn () => Podcast::all()], '/podcasts/1/2'],
'podcasts.many-by-model-fqn' => ['podcasts/[...Tests.Feature.Fixtures.Podcast].blade.php', ['podcasts' => fn () => Podcast::all()], '/podcasts/1/2'],
'podcasts.many-by-model-name-1' => ['podcasts/[...Podcast:name].blade.php', ['podcasts' => fn () => Podcast::all()], '/podcasts/test-podcast-name-1/test-podcast-name-2'],
'podcasts.many-by-model-name-2' => ['podcasts/[...Podcast-name].blade.php', ['podcasts' => fn () => Podcast::all()], '/podcasts/test-podcast-name-1/test-podcast-name-2'],
'podcasts.many-by-backed-enum' => ['podcasts/[...Category].blade.php', ['categories' => [Category::Post, Category::Video]], '/podcasts/posts/video'],
'podcasts.many-by-id-with-nested-page' => ['podcasts/[...id]/stats.blade.php', ['ids' => [1, 2, 3]], '/podcasts/1/2/3/stats'],
'articles.query-parameter' => ['articles.blade.php', ['page' => 1], '/articles?page=1'],
'articles.query-parameters' => ['articles.blade.php', ['page' => 1, 'lowerCase' => 'lowerCaseKeyValue', 'Upper_case-key' => 'Upper_caseKeyValue'], '/articles?page=1&lowerCase=lowerCaseKeyValue&Upper_case-key=Upper_caseKeyValue'],
'articles.query-array-parameter' => ['articles.blade.php', ['page' => [1, 2]], '/articles?page%5B0%5D=1&page%5B1%5D=2'],
])->map(function (array $value) {
$mountPath = 'resources/views/pages';

Expand All @@ -67,20 +77,106 @@
return [$mountPath, $mountPath.'/'.$viewRelativePath, $arguments, $expectedRoute];
})->mapWithKeys(fn (array $value, string $key) => [$key => [$key, $value]])->toArray());

it('may not have routes', function () {
it('may have absolute routes', function (string $name, array $scenario) {
[$mountPath, $viewPath, $domain, $arguments, $expectedRoute] = $scenario;

$arguments = collect($arguments)->map(fn ($argument) => value($argument))->all();

$names = new FolioRoutes(Mockery::mock(FolioManager::class), '', [
'podcasts.index' => ['resources/views/pages', 'resources/views/pages/podcasts/index.blade.php'],
$name => [
'mountPath' => $mountPath,
'path' => $viewPath,
'baseUri' => '/',
'domain' => $domain,
],
], true);

expect($names->has('podcasts.show'))->toBeFalse()
->and(fn () => $names->get('podcasts.show', [], false))->toThrow(RouteNotFoundException::class);
});
expect($names->has($name))->toBeTrue()
->and($names->get($name, $arguments, true))->toBe($expectedRoute);
})->with(fn () => collect([
'podcasts.index' => ['podcasts/index.blade.php', 'domain.com', [], 'http://domain.com/podcasts'],
'podcasts.show' => ['podcasts/[id].blade.php', 'domain.com', ['id' => 1], 'http://domain.com/podcasts/1'],
'podcasts.show-by-account-and-name' => ['podcasts/[name].blade.php', '{account}.domain.com', ['account' => 'taylor', 'name' => 'Taylor'], 'http://taylor.domain.com/podcasts/Taylor'],
'podcasts.show-by-account-and-name-and-pagination' => ['podcasts/[name].blade.php', '{account}.domain.com', ['account' => 'taylor', 'name' => 'Taylor', 'page' => 1], 'http://taylor.domain.com/podcasts/Taylor?page=1'],
])->map(function (array $value) {
$mountPath = 'resources/views/pages';

[$viewRelativePath, $domain, $arguments, $expectedRoute] = $value;

return [$mountPath, $mountPath.'/'.$viewRelativePath, $domain, $arguments, $expectedRoute];
})->mapWithKeys(fn (array $value, string $key) => [$key => [$key, $value]])->toArray());

it('may have routes with custom base uri', function (string $name, array $scenario) {
[$mountPath, $viewPath, $baseUri, $arguments, $expectedRoute] = $scenario;

$arguments = collect($arguments)->map(fn ($argument) => value($argument))->all();

it('can not have missing parameters', function () {
$names = new FolioRoutes(Mockery::mock(FolioManager::class), '', [
'podcasts.show' => ['resources/views/pages', 'resources/views/pages/podcasts/[id].blade.php'],
$name => [
'mountPath' => $mountPath,
'path' => $viewPath,
'baseUri' => $baseUri,
'domain' => null,
],
], true);

expect(fn () => $names->get('podcasts.show', [], false))
->toThrow(UrlGenerationException::class, 'Missing required parameter [id] for path [resources/views/pages/podcasts/[id]].');
expect($names->has($name))->toBeTrue()
->and($names->get($name, $arguments, true))->toBe($expectedRoute);
})->with(fn () => collect([
'podcasts.index' => ['podcasts/index.blade.php', '/a', [], 'http://localhost/a/podcasts'],
'podcasts.show' => ['podcasts/[id].blade.php', '/a/b', ['id' => 1], 'http://localhost/a/b/podcasts/1'],
'podcasts.show-by-account-and-name' => ['podcasts/[name].blade.php', '/a/b/c', ['name' => 'Taylor'], 'http://localhost/a/b/c/podcasts/Taylor'],
])->map(function (array $value) {
$mountPath = 'resources/views/pages';

[$viewRelativePath, $domain, $arguments, $expectedRoute] = $value;

return [$mountPath, $mountPath.'/'.$viewRelativePath, $domain, $arguments, $expectedRoute];
})->mapWithKeys(fn (array $value, string $key) => [$key => [$key, $value]])->toArray());

test('missing parameters', function (string $name, array $scenario) {
[$mountPath, $viewPath, $domain, $arguments, $expectedExpectationMessage] = $scenario;

$arguments = collect($arguments)->map(fn ($argument) => value($argument))->all();

$names = new FolioRoutes(Mockery::mock(FolioManager::class), '', [
$name => [
'mountPath' => $mountPath,
'path' => $viewPath,
'baseUri' => '/',
'domain' => $domain,
],
], true);

expect($names->has($name))->toBeTrue()
->and(fn () => $names->get($name, $arguments, true))->toThrow(
UrlGenerationException::class,
$expectedExpectationMessage,
);
})->with(fn () => collect([
'podcasts.show' => ['podcasts/[id].blade.php', 'domain.com', [], 'Missing required parameter [id] for path [resources/views/pages/podcasts/[id]].'],
'podcasts.show-by-account-and-name' => ['podcasts/[name].blade.php', '{account}.domain.com', ['name' => 'Taylor'], 'Missing required parameter for [Route: podcasts.show-by-account-and-name] [URI: /podcasts/Taylor] [Missing parameter: account].'],
'podcasts.show-by-account-and-name-and-pagination' => ['podcasts/[name].blade.php', '{account}.domain.com', ['account' => 'taylor', 'page' => 1], 'Missing required parameter [name] for path [resources/views/pages/podcasts/[name]].'],
'podcasts.show-by-account-and-account' => ['podcasts/[account].blade.php', '{account}.domain.com', ['account' => 'taylor'], 'Missing required parameter for [Route: podcasts.show-by-account-and-account] [URI: /podcasts/taylor] [Missing parameter: account].'],
'podcasts.show-by-account-and-{name}' => ['podcasts/name.blade.php', '{account}.domain.com', ['account' => '{name}', 'name' => 'nuno'], 'Missing required parameter for [Route: podcasts.show-by-account-and-{name}] [URI: /podcasts/name] [Missing parameter: name].'],
])->map(function (array $value) {
$mountPath = 'resources/views/pages';

[$viewRelativePath, $domain, $arguments, $expectedExpectationMessage] = $value;

return [$mountPath, $mountPath.'/'.$viewRelativePath, $domain, $arguments, $expectedExpectationMessage];
})->mapWithKeys(fn (array $value, string $key) => [$key => [$key, $value]])->toArray());

it('may not have routes', function () {
$names = new FolioRoutes(Mockery::mock(FolioManager::class), '', [
'podcasts.index' => [
'mountPath' => 'resources/views/pages',
'path' => 'resources/views/pages/podcasts/index.blade.php',
'baseUri' => '/',
'domain' => null,
],
], true);

expect($names->has('podcasts.show'))->toBeFalse()
->and(fn () => $names->get('podcasts.show', [], false))->toThrow(RouteNotFoundException::class);
});