Skip to content

Commit d59c4fd

Browse files
authored
feat: route auto-discovery
Merge pull request #310 from johntrickett86/route-auto-discovery
2 parents 686c16c + 0d5e481 commit d59c4fd

File tree

10 files changed

+825
-0
lines changed

10 files changed

+825
-0
lines changed

config/orion.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,4 +51,16 @@
5151
],
5252

5353
'use_validated' => false,
54+
55+
'route_discovery' => [
56+
'enabled' => true,
57+
'paths' => [
58+
app_path('Http/Controllers/Api'),
59+
],
60+
'route_prefix' => 'api',
61+
'route_name_prefix' => 'api',
62+
'route_middleware' => [
63+
// Add custom middleware here - eg: 'auth:sanctum',
64+
],
65+
],
5466
];
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Orion\Concerns;
4+
5+
trait DisableRouteDiscovery
6+
{
7+
/**
8+
* @var bool $routeDiscoveryDisabled
9+
*/
10+
protected $routeDiscoveryDisabled = true;
11+
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
<?php
2+
3+
namespace Orion\Concerns;
4+
5+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
6+
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
7+
use Illuminate\Database\Eloquent\Relations\HasMany;
8+
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
9+
use Illuminate\Database\Eloquent\Relations\HasOne;
10+
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
11+
use Illuminate\Database\Eloquent\Relations\MorphMany;
12+
use Illuminate\Database\Eloquent\Relations\MorphOne;
13+
use Illuminate\Database\Eloquent\Relations\MorphTo;
14+
use Illuminate\Database\Eloquent\Relations\MorphToMany;
15+
use Illuminate\Database\Eloquent\SoftDeletes;
16+
use Illuminate\Support\Arr;
17+
use Illuminate\Support\Facades\Route;
18+
use Orion\Exceptions\RouteDiscoveryException;
19+
use Orion\Facades\Orion;
20+
use Orion\Http\Controllers\RelationController;
21+
22+
trait HandlesRouteDiscovery
23+
{
24+
protected static $slug = null;
25+
protected static $routePrefix = null;
26+
protected static $routeNamePrefix = null;
27+
protected static $routeMiddleware = [];
28+
protected static $withoutRouteMiddleware = [];
29+
30+
/**
31+
* Determine whether route auto discovery is enabled.
32+
*
33+
* @return bool
34+
*/
35+
public function routeDiscoveryEnabled(): bool
36+
{
37+
return !property_exists($this, 'routeDiscoveryDisabled');
38+
}
39+
40+
public static function registerRoutes(): void
41+
{
42+
if (static::isRelationController()) {
43+
static::registerRelationRoutes();
44+
} else {
45+
static::registerResourceRoutes();
46+
}
47+
}
48+
49+
protected static function registerResourceRoutes(): void
50+
{
51+
$slug = static::getSlug();
52+
53+
Route::middleware(static::getRouteMiddleware())
54+
->withoutMiddleware(static::getWithoutRouteMiddleware())
55+
->prefix(static::getRoutePrefix())
56+
->name(static::getRouteNamePrefix() . '.')
57+
->group(function () use ($slug) {
58+
$controller = static::class;
59+
$route = Orion::resource($slug, $controller);
60+
61+
if (static::usesSoftDeletes($controller)) {
62+
$route->withSoftDeletes();
63+
}
64+
});
65+
}
66+
67+
protected static function registerRelationRoutes(): void
68+
{
69+
$controller = static::class;
70+
$instance = app($controller);
71+
72+
$model = $instance->model ?? null;
73+
$relation = isset($instance->relation) ? $instance->relation : null;
74+
$type = isset($instance->resourceType) ? $instance->resourceType : static::detectRelationType($model, $relation);
75+
76+
if (! $model || ! $relation || ! $type) {
77+
throw new RouteDiscoveryException("Cannot register relation route: model [$model], relation [$relation], type [$type]");
78+
}
79+
80+
$parentSlug = str(class_basename($model))->kebab()->plural();
81+
82+
Route::middleware(static::getRouteMiddleware())
83+
->withoutMiddleware(static::getWithoutRouteMiddleware())
84+
->prefix(static::getRoutePrefix())
85+
->name(static::getRouteNamePrefix() . '.')
86+
->group(function () use ($type, $parentSlug, $relation, $controller, $instance) {
87+
88+
switch ($type) {
89+
case 'hasOne':
90+
$route = Orion::hasOneResource($parentSlug, $relation, $controller);
91+
break;
92+
case 'hasMany':
93+
$route = Orion::hasManyResource($parentSlug, $relation, $controller);
94+
break;
95+
case 'belongsTo':
96+
$route = Orion::belongsToResource($parentSlug, $relation, $controller);
97+
break;
98+
case 'belongsToMany':
99+
$route = Orion::belongsToManyResource($parentSlug, $relation, $controller);
100+
break;
101+
case 'hasOneThrough':
102+
$route = Orion::hasOneThroughResource($parentSlug, $relation, $controller);
103+
break;
104+
case 'hasManyThrough':
105+
$route = Orion::hasManyThroughResource($parentSlug, $relation, $controller);
106+
break;
107+
case 'morphOne':
108+
$route = Orion::morphOneResource($parentSlug, $relation, $controller);
109+
break;
110+
case 'morphMany':
111+
$route = Orion::morphManyResource($parentSlug, $relation, $controller);
112+
break;
113+
case 'morphTo':
114+
$route = Orion::morphToResource($parentSlug, $relation, $controller);
115+
break;
116+
case 'morphToMany':
117+
$route = Orion::morphToManyResource($parentSlug, $relation, $controller);
118+
break;
119+
case 'morphedByMany':
120+
$route = Orion::morphedByManyResource($parentSlug, $relation, $controller);
121+
break;
122+
default:
123+
throw new RouteDiscoveryException("Unsupported relation type [$type] on [$parentSlug -> $relation]");
124+
}
125+
126+
if (static::usesRelatedSoftDeletes($instance)) {
127+
$route->withSoftDeletes();
128+
}
129+
});
130+
}
131+
132+
protected static function isRelationController(): bool
133+
{
134+
return is_subclass_of(static::class, RelationController::class);
135+
}
136+
137+
protected static function detectRelationType($model, $relation): ?string
138+
{
139+
if (! method_exists($model, $relation)) {
140+
return null;
141+
}
142+
143+
$instance = new $model;
144+
$relationInstance = $instance->{$relation}();
145+
146+
$map = [
147+
HasOne::class => 'hasOne',
148+
HasOneThrough::class => 'hasOneThrough',
149+
MorphOne::class => 'morphOne',
150+
BelongsTo::class => 'belongsTo',
151+
MorphTo::class => 'morphTo',
152+
HasMany::class => 'hasMany',
153+
HasManyThrough::class => 'hasManyThrough',
154+
MorphMany::class => 'morphMany',
155+
BelongsToMany::class => 'belongsToMany',
156+
MorphToMany::class => static::isMorphedByMany($model, $relation) ? 'morphedByMany' : 'morphToMany',
157+
];
158+
159+
foreach ($map as $class => $type) {
160+
if ($relationInstance instanceof $class) {
161+
return $type;
162+
}
163+
}
164+
165+
return null;
166+
}
167+
168+
protected static function isMorphedByMany($model, $relation): bool
169+
{
170+
$instance = new $model;
171+
172+
if (! method_exists($instance, $relation)) {
173+
return false;
174+
}
175+
176+
$relationInstance = $instance->{$relation}();
177+
178+
return $relationInstance instanceof MorphToMany && $relationInstance->getInverse();
179+
}
180+
181+
protected static function usesSoftDeletes($controller): bool
182+
{
183+
$instance = app($controller);
184+
185+
if (! method_exists($instance, 'resolveResourceModelClass')) {
186+
return false;
187+
}
188+
189+
$modelClass = $instance->resolveResourceModelClass();
190+
191+
return class_exists($modelClass)
192+
&& in_array(SoftDeletes::class, class_uses_recursive($modelClass));
193+
}
194+
195+
protected static function usesRelatedSoftDeletes($controller): bool
196+
{
197+
$model = $controller->model ?? null;
198+
$relation = $controller->relation ?? null;
199+
200+
if (! $model || ! method_exists($model, $relation)) {
201+
return false;
202+
}
203+
204+
$related = $model::query()->getModel()->{$relation}()->getRelated();
205+
206+
return in_array(SoftDeletes::class, class_uses_recursive($related));
207+
}
208+
209+
public static function getSlug(): string
210+
{
211+
if (! empty(static::$slug)) {
212+
return static::$slug;
213+
}
214+
215+
return (string) str(class_basename(static::class))
216+
->beforeLast('Controller')
217+
->kebab()
218+
->plural();
219+
}
220+
221+
public static function getRoutePrefix(): string
222+
{
223+
return static::$routePrefix ?: config('orion.route_discovery.route_prefix', 'api');
224+
}
225+
226+
public static function getRouteNamePrefix(): string
227+
{
228+
return static::$routeNamePrefix ?: config('orion.route_discovery.route_name_prefix', 'api');
229+
}
230+
231+
public static function getRouteName(): string
232+
{
233+
return static::getRouteNamePrefix() . '.' . static::getRelativeRouteName();
234+
}
235+
236+
public static function getRoutePath(): string
237+
{
238+
return '/' . static::getSlug();
239+
}
240+
241+
public static function getRelativeRouteName(): string
242+
{
243+
return (string) str(static::getSlug())->replace('/', '.');
244+
}
245+
246+
public static function getRouteMiddleware()
247+
{
248+
return array_merge(
249+
config('orion.route_discovery.route_middleware', []),
250+
Arr::wrap(static::$routeMiddleware)
251+
);
252+
}
253+
254+
public static function getWithoutRouteMiddleware(): array
255+
{
256+
return Arr::wrap(static::$withoutRouteMiddleware);
257+
}
258+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace Orion\Exceptions;
4+
5+
class RouteDiscoveryException extends \Exception
6+
{
7+
8+
}

src/Http/Controllers/BaseController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Illuminate\Support\Facades\Auth;
1616
use Orion\Concerns\BuildsResponses;
1717
use Orion\Concerns\HandlesAuthorization;
18+
use Orion\Concerns\HandlesRouteDiscovery;
1819
use Orion\Concerns\HandlesTransactions;
1920
use Orion\Concerns\InteractsWithBatchResources;
2021
use Orion\Concerns\InteractsWithHooks;
@@ -39,6 +40,7 @@ abstract class BaseController extends \Illuminate\Routing\Controller
3940
InteractsWithSoftDeletes,
4041
InteractsWithBatchResources,
4142
BuildsResponses,
43+
HandlesRouteDiscovery,
4244
HandlesTransactions;
4345

4446
/**

src/OrionServiceProvider.php

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
namespace Orion;
44

5+
use Illuminate\Support\Facades\File;
56
use Illuminate\Support\ServiceProvider;
7+
use Illuminate\Support\Str;
68
use Orion\Commands\BuildSpecsCommand;
79
use Orion\Contracts\ComponentsResolver;
810
use Orion\Contracts\KeyResolver;
@@ -11,6 +13,8 @@
1113
use Orion\Contracts\QueryBuilder;
1214
use Orion\Contracts\RelationsResolver;
1315
use Orion\Contracts\SearchBuilder;
16+
use Orion\Http\Controllers\Controller;
17+
use Orion\Http\Controllers\RelationController;
1418
use Orion\Http\Middleware\EnforceExpectsJson;
1519
use Orion\Specs\ResourcesCacheStore;
1620

@@ -60,5 +64,55 @@ public function boot()
6064
]
6165
);
6266
}
67+
68+
if (config('orion.route_discovery.enabled', false)) {
69+
$this->discoverAndRegisterControllers();
70+
}
71+
}
72+
73+
protected function discoverAndRegisterControllers(): void
74+
{
75+
$paths = config('orion.route_discovery.paths', []);
76+
77+
foreach ($paths as $path) {
78+
if (! is_dir($path)) {
79+
continue;
80+
}
81+
82+
$namespace = $this->pathToNamespace($path);
83+
84+
foreach (File::allFiles($path) as $file) {
85+
$class = $namespace . '\\' . str_replace(
86+
['/', '.php'],
87+
['\\', ''],
88+
$file->getRelativePathname()
89+
);
90+
91+
if (
92+
class_exists($class) &&
93+
(
94+
is_subclass_of($class, Controller::class) ||
95+
is_subclass_of($class, RelationController::class)
96+
)
97+
) {
98+
$instance = app($class);
99+
100+
if (method_exists($instance, 'routeDiscoveryEnabled') && !$instance->routeDiscoveryEnabled()) {
101+
continue;
102+
}
103+
104+
$class::registerRoutes();
105+
}
106+
}
107+
}
108+
}
109+
110+
protected function pathToNamespace(string $path): string
111+
{
112+
return Str::of($path)
113+
->after(base_path('app'))
114+
->replace('/', '\\')
115+
->prepend('App')
116+
->__toString();
63117
}
64118
}

0 commit comments

Comments
 (0)