Skip to content

Commit

Permalink
feat: allow routing customization
Browse files Browse the repository at this point in the history
  • Loading branch information
Mohammad-Alavi committed Jan 17, 2025
1 parent 14ea5dc commit 2a76d9c
Show file tree
Hide file tree
Showing 9 changed files with 181 additions and 71 deletions.
14 changes: 0 additions & 14 deletions config/apiato.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,6 @@
*/
'url' => env('API_URL', 'http://localhost'),

/*
|--------------------------------------------------------------------------
| API Prefix
|--------------------------------------------------------------------------
*/
'prefix' => env('API_PREFIX', '/'),

/*
|--------------------------------------------------------------------------
| API Version Prefix
|--------------------------------------------------------------------------
*/
'enable_version_prefix' => true,

/*
|--------------------------------------------------------------------------
| Access Token Expiration Time
Expand Down
6 changes: 5 additions & 1 deletion src/Foundation/Apiato.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ public function basePath(): string

public function withRouting(callable|null $callback = null): self
{
// TODO: maybe make the configuration parametrized like web: api:, like the way Laravel does it?
$this->routing ??= new Routing();

if (!is_null($callback)) {
Expand Down Expand Up @@ -195,6 +194,11 @@ public function seeding(): Seeding
return $this->seeding;
}

public function routing(): Routing
{
return $this->routing;
}

public function factoryDiscovery(): FactoryDiscovery
{
return $this->factoryDiscovery;
Expand Down
111 changes: 62 additions & 49 deletions src/Foundation/Configuration/Routing.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,38 @@

namespace Apiato\Foundation\Configuration;

use Illuminate\Support\Arr;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route as LaravelRoute;
use Symfony\Component\Finder\SplFileInfo;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Webmozart\Assert\Assert;

final class Routing
{
protected static \Closure $apiVersionResolver;
/** @var string[] */
private array $apiRouteDirs = [];
/** @var string[] */
private array $webRouteDirs = [];
private string $apiPrefix = '/';
private bool $apiVersionAutoPrefix = true;

public function __construct()
{
$this->resolveApiVersionUsing(
static function (string $file): string {
return Str::of($file)
->before('.php')
->betweenFirst('.', '.')
->value();
},
);
}

public function resolveApiVersionUsing(callable $callback): self
{
self::$apiVersionResolver = $callback;

return $this;
}

public function loadApiRoutesFrom(string ...$path): self
{
Expand All @@ -29,76 +52,66 @@ public function loadWebRoutesFrom(string ...$path): self
public function registerApiRoutes(): void
{
collect($this->apiRouteDirs)
->map(fn ($path) => $this->getFilesSortedByName($path))
->flatten()
->each(fn (SplFileInfo $file) => $this->loadApiRoute($file));
->flatMap(static fn ($path) => glob($path . '/*.php'))
->each(
function (string $file) {
return Route::middleware($this->getApiMiddlewares())
->domain(config('apiato.api.url'))
->prefix($this->buildApiPrefixFor($file))
->group($file);
},
);
}

/**
* @return SplFileInfo[]
*/
private function getFilesSortedByName(string $apiRoutesPath): array
private function getApiMiddlewares(): array
{
$files = File::allFiles($apiRoutesPath);
$middlewares = ['api'];
if (config('apiato.api.rate-limiter.enabled')) {
$middlewares[] = 'throttle:' . config('apiato.api.rate-limiter.name');
}

return Arr::sort($files, static fn ($file) => $file->getFilename());
return $middlewares;
}

private function loadApiRoute(SplFileInfo $file): void
private function buildApiPrefixFor(string $file): string
{
$routeGroupArray = $this->getApiRouteGroup($file);
if ($this->apiVersionAutoPrefix) {
return $this->apiPrefix . $this->resolveApiVersionFor($file);
}

LaravelRoute::group($routeGroupArray, static function () use ($file): void {
require $file->getPathname();
});
return $this->apiPrefix;
}

private function getApiRouteGroup(SplFileInfo|string $endpointFileOrPrefixString): array
private function resolveApiVersionFor(string $file): string
{
return [
'middleware' => $this->getMiddlewares(),
'domain' => config('apiato.api.url'),
// If $endpointFileOrPrefixString is a string, use that string as prefix
// else, if it is a file then get the version name from the file name, and use it as prefix
'prefix' => is_string($endpointFileOrPrefixString) ? $endpointFileOrPrefixString : $this->getApiVersionPrefix($endpointFileOrPrefixString),
];
return app()->call(self::$apiVersionResolver, compact('file'));
}

private function getMiddlewares(): array
public function getApiPrefix(): string
{
$middlewares = ['api'];
if (config('apiato.api.rate-limiter.enabled')) {
$middlewares[] = 'throttle:' . config('apiato.api.rate-limiter.name');
}

return $middlewares;
return $this->apiPrefix;
}

private function getApiVersionPrefix(SplFileInfo $file): string
public function disableApiVersionAutoPrefix(): self
{
return config('apiato.api.prefix') . (config('apiato.api.enable_version_prefix') ? $this->getRouteFileVersionFromFileName($file) : '');
$this->apiVersionAutoPrefix = false;

return $this;
}

private function getRouteFileVersionFromFileName(SplFileInfo $file): string|bool
public function prefixApiUrlsWith(string $prefix = '/'): self
{
$fileNameWithoutExtension = pathinfo($file->getFilename(), PATHINFO_FILENAME);
$fileNameWithoutExtensionExploded = explode('.', $fileNameWithoutExtension);
Assert::nullOrRegex($prefix, '/^.*\/$/', 'The API prefix must end with a slash.');

end($fileNameWithoutExtensionExploded);
$this->apiPrefix = $prefix;

return prev($fileNameWithoutExtensionExploded);
return $this;
}

public function webRoutes(): array
{
$files = [];
foreach ($this->webRouteDirs as $path) {
foreach (glob($path . '/*.php') as $file) {
$files[] = $file;
}
}
usort($files, 'strcmp');

return $files;
return collect($this->webRouteDirs)
->flatMap(static fn ($path) => glob($path . '/*.php'))
->toArray();
}
}
8 changes: 2 additions & 6 deletions src/Foundation/Support/Traits/Testing/RequestHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

namespace Apiato\Foundation\Support\Traits\Testing;

use Apiato\Foundation\Apiato;
use Apiato\Foundation\Exceptions\MissingTestEndpoint;
use Apiato\Foundation\Exceptions\UndefinedMethod;
use Apiato\Foundation\Exceptions\WrongEndpointFormat;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Illuminate\Testing\TestResponse;
use Vinkla\Hashids\Facades\Hashids;

Expand Down Expand Up @@ -138,11 +138,7 @@ private function validateEndpointFormat(string $separator): void

private function buildUrlForUri($uri): string
{
$uri = config('apiato.api.prefix') . $uri;

if (!Str::startsWith($uri, '/')) {
$uri = '/' . $uri;
}
$uri = Apiato::instance()->routing()->getApiPrefix() . $uri;

return $this->getUrl() . $uri;
}
Expand Down
2 changes: 1 addition & 1 deletion tests/Functional/Providers/ApplicationBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
it('load api routes from configured path', function (): void {
$endpoints = [
'/v3/authors',
'/v1/books',
'/v4/books',
];

expect($endpoints)
Expand Down
111 changes: 111 additions & 0 deletions tests/Unit/Foundation/Configuration/RoutingTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<?php

use Apiato\Foundation\Configuration\Routing;
use Illuminate\Routing\Route;
use Illuminate\Support\Collection;

describe(class_basename(Routing::class), function (): void {
it('can collect web routes from paths', function (): void {
$configuration = new Routing();
$configuration->loadWebRoutesFrom(
app_path('Containers/*/*/UI/WEB/Routes'),
);

expect($configuration->webRoutes())->toBe([
app_path('Containers/MySection/Author/UI/WEB/Routes/ListAuthors.php'),
app_path('Containers/MySection/Book/UI/WEB/Routes/CreateBook.v1.public.php'),
app_path('Containers/MySection/Book/UI/WEB/Routes/ListBooks.php'),
]);
});

it('can collect api routes from paths', function (): void {
$configuration = new Routing();
$configuration->loadApiRoutesFrom(
app_path('Containers/*/*/UI/API/Routes'),
);

expect($configuration->apiRoutes())->toBe([
app_path('Containers/MySection/Author/UI/API/Routes/ListAuthors.v3.public.php'),
app_path('Containers/MySection/Book/UI/API/Routes/CreateBook.v1.private.php'),
app_path('Containers/MySection/Book/UI/API/Routes/ListBooks.v1.private.php'),
]);
})->todo();

it('can set api prefix', function (): void {
$configuration = new Routing();
$configuration->prefixApiUrlsWith('api/');

expect($configuration->getApiPrefix())->toBe('api/');
});

it('does not apply api version from the route file name for web routes', function (): void {
$configuration = new Routing();
$configuration->loadWebRoutesFrom(
app_path('Containers/*/*/UI/WEB/Routes'),
);

$webRoutes = getRoutesByMiddleware('web')->map(
static fn (Route $route) => $route->uri(),
)->toArray();

expect($webRoutes)->toBe([
'books/create',
'books',
'authors',
]);
});

it('applies api version prefix from the route file name', function (): void {
$configuration = new Routing();
$configuration->loadApiRoutesFrom(
app_path('Containers/*/*/UI/API/Routes'),
);

$apiRoutes = getRoutesByMiddleware('api')->flatMap(
static fn (Route $route) => [
$route->methods(),
$route->uri(),
$route->gatherMiddleware(),
$route->domain(),
],
)->toArray();

expect($apiRoutes)->toBe([
['POST'],
'v1/books',
[
'api',
'throttle:api',
],
'localhost',
['GET', 'HEAD'],
'v4/books',
[
'api',
'throttle:api',
],
'localhost',
['GET', 'HEAD'],
'v3/authors',
[
'api',
'throttle:api',
],
'localhost',
]);
});

/**
* @return Collection<int, Route>
*/
function getRoutesByMiddleware(string $middleware): Collection
{
$routes = Illuminate\Support\Facades\Route::getRoutes()->getRoutes();

return collect($routes)
->filter(
static fn (Route $route) => collect($route->gatherMiddleware())
->contains($middleware),
);
}
})->covers(Routing::class);

0 comments on commit 2a76d9c

Please sign in to comment.