Skip to content

API Route Auto Discovery #310

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
12 changes: 12 additions & 0 deletions config/orion.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,16 @@
],

'use_validated' => false,

'route_discovery' => [
'enabled' => true,
'paths' => [
app_path('Http/Controllers/Api'),
],
'route_prefix' => 'api',
'route_name_prefix' => 'api',
'route_middleware' => [
// Add custom middleware here - eg: 'auth:sanctum',
],
],
];
11 changes: 11 additions & 0 deletions src/Concerns/DisableRouteDiscovery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Orion\Concerns;

trait DisableRouteDiscovery
{
/**
* @var bool $routeDiscoveryDisabled
*/
protected $routeDiscoveryDisabled = true;
}
258 changes: 258 additions & 0 deletions src/Concerns/HandlesRouteDiscovery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
<?php

namespace Orion\Concerns;

use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Route;
use Orion\Exceptions\RouteDiscoveryException;
use Orion\Facades\Orion;
use Orion\Http\Controllers\RelationController;

trait HandlesRouteDiscovery
{
protected static $slug = null;
protected static $routePrefix = null;
protected static $routeNamePrefix = null;
protected static $routeMiddleware = [];
protected static $withoutRouteMiddleware = [];

/**
* Determine whether route auto discovery is enabled.
*
* @return bool
*/
public function routeDiscoveryEnabled(): bool
{
return !property_exists($this, 'routeDiscoveryDisabled');
}

public static function registerRoutes(): void
{
if (static::isRelationController()) {
static::registerRelationRoutes();
} else {
static::registerResourceRoutes();
}
}

protected static function registerResourceRoutes(): void
{
$slug = static::getSlug();

Route::middleware(static::getRouteMiddleware())
->withoutMiddleware(static::getWithoutRouteMiddleware())
->prefix(static::getRoutePrefix())
->name(static::getRouteNamePrefix() . '.')
->group(function () use ($slug) {
$controller = static::class;
$route = Orion::resource($slug, $controller);

if (static::usesSoftDeletes($controller)) {
$route->withSoftDeletes();
}
});
}

protected static function registerRelationRoutes(): void
{
$controller = static::class;
$instance = app($controller);

$model = $instance->model ?? null;
$relation = isset($instance->relation) ? $instance->relation : null;
$type = isset($instance->resourceType) ? $instance->resourceType : static::detectRelationType($model, $relation);

if (! $model || ! $relation || ! $type) {
throw new RouteDiscoveryException("Cannot register relation route: model [$model], relation [$relation], type [$type]");
}

$parentSlug = str(class_basename($model))->kebab()->plural();

Route::middleware(static::getRouteMiddleware())
->withoutMiddleware(static::getWithoutRouteMiddleware())
->prefix(static::getRoutePrefix())
->name(static::getRouteNamePrefix() . '.')
->group(function () use ($type, $parentSlug, $relation, $controller, $instance) {

switch ($type) {
case 'hasOne':
$route = Orion::hasOneResource($parentSlug, $relation, $controller);
break;
case 'hasMany':
$route = Orion::hasManyResource($parentSlug, $relation, $controller);
break;
case 'belongsTo':
$route = Orion::belongsToResource($parentSlug, $relation, $controller);
break;
case 'belongsToMany':
$route = Orion::belongsToManyResource($parentSlug, $relation, $controller);
break;
case 'hasOneThrough':
$route = Orion::hasOneThroughResource($parentSlug, $relation, $controller);
break;
case 'hasManyThrough':
$route = Orion::hasManyThroughResource($parentSlug, $relation, $controller);
break;
case 'morphOne':
$route = Orion::morphOneResource($parentSlug, $relation, $controller);
break;
case 'morphMany':
$route = Orion::morphManyResource($parentSlug, $relation, $controller);
break;
case 'morphTo':
$route = Orion::morphToResource($parentSlug, $relation, $controller);
break;
case 'morphToMany':
$route = Orion::morphToManyResource($parentSlug, $relation, $controller);
break;
case 'morphedByMany':
$route = Orion::morphedByManyResource($parentSlug, $relation, $controller);
break;
default:
throw new RouteDiscoveryException("Unsupported relation type [$type] on [$parentSlug -> $relation]");
}

if (static::usesRelatedSoftDeletes($instance)) {
$route->withSoftDeletes();
}
});
}

protected static function isRelationController(): bool
{
return is_subclass_of(static::class, RelationController::class);
}

protected static function detectRelationType($model, $relation): ?string
{
if (! method_exists($model, $relation)) {
return null;
}

$instance = new $model;
$relationInstance = $instance->{$relation}();

$map = [
HasOne::class => 'hasOne',
HasOneThrough::class => 'hasOneThrough',
MorphOne::class => 'morphOne',
BelongsTo::class => 'belongsTo',
MorphTo::class => 'morphTo',
HasMany::class => 'hasMany',
HasManyThrough::class => 'hasManyThrough',
MorphMany::class => 'morphMany',
BelongsToMany::class => 'belongsToMany',
MorphToMany::class => static::isMorphedByMany($model, $relation) ? 'morphedByMany' : 'morphToMany',
];

foreach ($map as $class => $type) {
if ($relationInstance instanceof $class) {
return $type;
}
}

return null;
}

protected static function isMorphedByMany($model, $relation): bool
{
$instance = new $model;

if (! method_exists($instance, $relation)) {
return false;
}

$relationInstance = $instance->{$relation}();

return $relationInstance instanceof MorphToMany && $relationInstance->getInverse();
}

protected static function usesSoftDeletes($controller): bool
{
$instance = app($controller);

if (! method_exists($instance, 'resolveResourceModelClass')) {
return false;
}

$modelClass = $instance->resolveResourceModelClass();

return class_exists($modelClass)
&& in_array(SoftDeletes::class, class_uses_recursive($modelClass));
}

protected static function usesRelatedSoftDeletes($controller): bool
{
$model = $controller->model ?? null;
$relation = $controller->relation ?? null;

if (! $model || ! method_exists($model, $relation)) {
return false;
}

$related = $model::query()->getModel()->{$relation}()->getRelated();

return in_array(SoftDeletes::class, class_uses_recursive($related));
}

public static function getSlug(): string
{
if (! empty(static::$slug)) {
return static::$slug;
}

return (string) str(class_basename(static::class))
->beforeLast('Controller')
->kebab()
->plural();
}

public static function getRoutePrefix(): string
{
return static::$routePrefix ?: config('orion.route_discovery.route_prefix', 'api');
}

public static function getRouteNamePrefix(): string
{
return static::$routeNamePrefix ?: config('orion.route_discovery.route_name_prefix', 'api');
}

public static function getRouteName(): string
{
return static::getRouteNamePrefix() . '.' . static::getRelativeRouteName();
}

public static function getRoutePath(): string
{
return '/' . static::getSlug();
}

public static function getRelativeRouteName(): string
{
return (string) str(static::getSlug())->replace('/', '.');
}

public static function getRouteMiddleware()
{
return array_merge(
config('orion.route_discovery.route_middleware', []),
Arr::wrap(static::$routeMiddleware)
);
}

public static function getWithoutRouteMiddleware(): array
{
return Arr::wrap(static::$withoutRouteMiddleware);
}
}
8 changes: 8 additions & 0 deletions src/Exceptions/RouteDiscoveryException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace Orion\Exceptions;

class RouteDiscoveryException extends \Exception
{

}
2 changes: 2 additions & 0 deletions src/Http/Controllers/BaseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Illuminate\Support\Facades\Auth;
use Orion\Concerns\BuildsResponses;
use Orion\Concerns\HandlesAuthorization;
use Orion\Concerns\HandlesRouteDiscovery;
use Orion\Concerns\HandlesTransactions;
use Orion\Concerns\InteractsWithBatchResources;
use Orion\Concerns\InteractsWithHooks;
Expand All @@ -39,6 +40,7 @@ abstract class BaseController extends \Illuminate\Routing\Controller
InteractsWithSoftDeletes,
InteractsWithBatchResources,
BuildsResponses,
HandlesRouteDiscovery,
HandlesTransactions;

/**
Expand Down
54 changes: 54 additions & 0 deletions src/OrionServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

namespace Orion;

use Illuminate\Support\Facades\File;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Orion\Commands\BuildSpecsCommand;
use Orion\Contracts\ComponentsResolver;
use Orion\Contracts\KeyResolver;
Expand All @@ -11,6 +13,8 @@
use Orion\Contracts\QueryBuilder;
use Orion\Contracts\RelationsResolver;
use Orion\Contracts\SearchBuilder;
use Orion\Http\Controllers\Controller;
use Orion\Http\Controllers\RelationController;
use Orion\Http\Middleware\EnforceExpectsJson;
use Orion\Specs\ResourcesCacheStore;

Expand Down Expand Up @@ -60,5 +64,55 @@ public function boot()
]
);
}

if (config('orion.route_discovery.enabled', false)) {
$this->discoverAndRegisterControllers();
}
}

protected function discoverAndRegisterControllers(): void
{
$paths = config('orion.route_discovery.paths', []);

foreach ($paths as $path) {
if (! is_dir($path)) {
continue;
}

$namespace = $this->pathToNamespace($path);

foreach (File::allFiles($path) as $file) {
$class = $namespace . '\\' . str_replace(
['/', '.php'],
['\\', ''],
$file->getRelativePathname()
);

if (
class_exists($class) &&
(
is_subclass_of($class, Controller::class) ||
is_subclass_of($class, RelationController::class)
)
) {
$instance = app($class);

if (method_exists($instance, 'routeDiscoveryEnabled') && !$instance->routeDiscoveryEnabled()) {
continue;
}

$class::registerRoutes();
}
}
}
}

protected function pathToNamespace(string $path): string
{
return Str::of($path)
->after(base_path('app'))
->replace('/', '\\')
->prepend('App')
->__toString();
}
}
Loading