Skip to content

Commit 613e77a

Browse files
committed
Add OpenApi interface
1 parent 31137a2 commit 613e77a

17 files changed

Lines changed: 2443 additions & 159 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Console\Commands;
6+
7+
use App\Console\LnmsCommand;
8+
use App\Services\Api\OpenApi\OpenApiGenerator;
9+
use GoldSpecDigital\ObjectOrientedOAS\Exceptions\ValidationException;
10+
use Symfony\Component\Console\Input\InputOption;
11+
12+
class ApiOpenApiCommand extends LnmsCommand
13+
{
14+
protected $name = 'api:openapi';
15+
protected $description = 'Generate the OpenAPI 3 specification for the v1 API';
16+
17+
public function __construct()
18+
{
19+
parent::__construct();
20+
21+
$this->addOption('output', null, InputOption::VALUE_REQUIRED, 'Write the spec to PATH instead of stdout');
22+
$this->addOption('validate', null, InputOption::VALUE_NONE, 'Validate the spec and exit non-zero on errors');
23+
$this->addOption('no-pretty', null, InputOption::VALUE_NONE, 'Emit compact JSON');
24+
}
25+
26+
public function handle(OpenApiGenerator $generator): int
27+
{
28+
$spec = $generator->generate();
29+
30+
if ($this->option('validate')) {
31+
try {
32+
$spec->validate();
33+
} catch (ValidationException $e) {
34+
$this->error($e->getMessage());
35+
36+
return 1;
37+
}
38+
}
39+
40+
$flags = $this->option('no-pretty') ? 0 : JSON_PRETTY_PRINT;
41+
$json = $spec->toJson($flags);
42+
43+
$output = $this->option('output');
44+
if ($output !== null) {
45+
file_put_contents($output, $json);
46+
$this->info("Wrote {$output}");
47+
48+
return 0;
49+
}
50+
51+
$this->line($json);
52+
53+
return 0;
54+
}
55+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Api\V1;
6+
7+
use App\Http\Controllers\Controller;
8+
use App\Services\Api\OpenApi\OpenApiGenerator;
9+
use Illuminate\Http\JsonResponse;
10+
use Illuminate\Http\Request;
11+
use Illuminate\Support\Facades\Cache;
12+
use Illuminate\View\View;
13+
14+
class OpenApiController extends Controller
15+
{
16+
public function __construct(private readonly OpenApiGenerator $generator)
17+
{
18+
}
19+
20+
public function spec(Request $request): JsonResponse
21+
{
22+
$array = $request->boolean('fresh')
23+
? $this->generator->generate()->toArray()
24+
: Cache::remember(
25+
'api.v1.openapi.spec',
26+
300,
27+
fn () => $this->generator->generate()->toArray(),
28+
);
29+
30+
return new JsonResponse($array);
31+
}
32+
33+
public function docs(): View
34+
{
35+
return view('api.v1.docs', [
36+
'specUrl' => route('v1.openapi'),
37+
'cdnUrl' => 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5',
38+
]);
39+
}
40+
}

app/Http/Controllers/ApiAccessController.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@
2424
namespace App\Http\Controllers;
2525

2626
use App\Models\ApiToken;
27+
use App\Models\User;
2728
use Illuminate\Http\JsonResponse;
2829
use Illuminate\Http\RedirectResponse;
2930
use Illuminate\Http\Request;
3031
use Illuminate\View\View;
32+
use Laravel\Sanctum\PersonalAccessToken;
3133
use LibreNMS\Authentication\LegacyAuth;
3234

3335
class ApiAccessController extends Controller
@@ -46,8 +48,15 @@ public function index(Request $request): View
4648
->orderBy('id')
4749
->get();
4850

51+
$v1Tokens = PersonalAccessToken::query()
52+
->where('tokenable_type', User::class)
53+
->where('tokenable_id', $user->user_id)
54+
->orderBy('id')
55+
->get();
56+
4957
return view('user.api-access', [
5058
'tokens' => $tokens,
59+
'v1_tokens' => $v1Tokens,
5160
'legacy_auth_type' => LegacyAuth::getType(),
5261
]);
5362
}

app/Models/AlertRule.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ protected static function booted(): void
7171
'extra' => 'array',
7272
];
7373

74+
protected $attributes = [
75+
'extra' => '{}',
76+
'builder' => '{}',
77+
'query' => '',
78+
];
79+
7480
// ---- Query scopes ----
7581

7682
/**

app/Providers/RestifyServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
use App\Restify\VrfLiteRepository;
8181
use App\Restify\VrfRepository;
8282
use App\Http\Controllers\Api\V1\HealthController;
83+
use App\Http\Controllers\Api\V1\OpenApiController;
8384
use App\Http\Controllers\Api\V1\SystemController;
8485
use Binaryk\LaravelRestify\Bootstrap\RoutesBoot;
8586
use Binaryk\LaravelRestify\Restify;
@@ -105,6 +106,8 @@ protected function routes(): void
105106
Route::get('system', SystemController::class)
106107
->middleware(['auth:sanctum', 'can:settings.view'])
107108
->name('v1.system');
109+
Route::get('openapi.json', [OpenApiController::class, 'spec'])->name('v1.openapi');
110+
Route::get('docs', [OpenApiController::class, 'docs'])->name('v1.docs');
108111
});
109112

110113
parent::routes();

app/Restify/AlertRepository.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class AlertRepository extends Repository
2121

2222
public static string $title = 'id';
2323

24+
protected static array $disabledActions = ['update'];
25+
2426

2527

2628

app/Restify/Repository.php

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,18 @@
99
use Illuminate\Database\Eloquent\Relations\Relation;
1010
use Illuminate\Http\Request;
1111
use Illuminate\Support\Facades\Gate;
12+
use ReflectionMethod;
1213

1314
abstract class Repository extends RestifyRepository
1415
{
16+
/**
17+
* Actions to suppress from the OpenAPI document and (by convention) from custom routes().
18+
* Subclasses may override with any of: 'store', 'update', 'destroy'.
19+
*
20+
* @var string[]
21+
*/
22+
protected static array $disabledActions = [];
23+
1524
public static function authorizedToUseRepository(Request $request): bool
1625
{
1726
$user = $request->user();
@@ -37,4 +46,52 @@ public static function showQuery(RestifyRequest $request, Builder|Relation $quer
3746
{
3847
return $query;
3948
}
49+
50+
/**
51+
* Whether the action should appear in the OpenAPI document. Combines the explicit
52+
* $disabledActions list, any LibreNMS-side authorizedTo<Action> override (which by
53+
* convention returns false unconditionally), and policy method existence.
54+
*/
55+
public static function actionEnabled(string $action): bool
56+
{
57+
if (in_array($action, static::$disabledActions, true)) {
58+
return false;
59+
}
60+
61+
$authMethod = match ($action) {
62+
'store' => 'authorizedToStore',
63+
'update' => 'authorizedToUpdate',
64+
'destroy' => 'authorizedToDelete',
65+
default => null,
66+
};
67+
68+
$policyMethod = match ($action) {
69+
'store' => 'create',
70+
'update' => 'update',
71+
'destroy' => 'delete',
72+
default => null,
73+
};
74+
75+
if ($authMethod === null || $policyMethod === null) {
76+
return false;
77+
}
78+
79+
$declaringClass = (new ReflectionMethod(static::class, $authMethod))->getDeclaringClass()->getName();
80+
if ($declaringClass !== self::class && str_starts_with($declaringClass, 'App\\Restify\\')) {
81+
return false;
82+
}
83+
84+
if (! property_exists(static::class, 'model')) {
85+
return false;
86+
}
87+
/** @var class-string<\Illuminate\Database\Eloquent\Model>|null $modelClass */
88+
$modelClass = static::$model ?? null; // @phpstan-ignore staticProperty.notFound
89+
if (! is_string($modelClass) || ! class_exists($modelClass)) {
90+
return false;
91+
}
92+
93+
$policy = Gate::getPolicyFor(new $modelClass());
94+
95+
return is_object($policy) && method_exists($policy, $policyMethod);
96+
}
4097
}

0 commit comments

Comments
 (0)