Skip to content

Commit ef13136

Browse files
committed
- add OpenApi documentation
- Improve api error handling - add attach/detach/sync logic and policies for relationships - add actions for rediscovery, muting and maintenance - reallign relationships to follow naming convention
1 parent 518e709 commit ef13136

42 files changed

Lines changed: 2253 additions & 57 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/Exceptions/ErrorReporting.php

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,24 @@
2727
namespace App\Exceptions;
2828

2929
use App\Facades\LibrenmsConfig;
30+
use App\Http\Middleware\EnforceJsonApi;
31+
use Binaryk\LaravelRestify\Exceptions\RepositoryNotFoundException;
32+
use Illuminate\Auth\Access\AuthorizationException;
33+
use Illuminate\Auth\AuthenticationException;
3034
use Illuminate\Cache\RateLimiting\Limit;
35+
use Illuminate\Database\Eloquent\ModelNotFoundException;
3136
use Illuminate\Foundation\Configuration\Exceptions;
37+
use Illuminate\Http\Exceptions\ThrottleRequestsException;
38+
use Illuminate\Http\JsonResponse;
3239
use Illuminate\Http\Request;
3340
use Illuminate\Http\Response;
3441
use Illuminate\Support\Str;
42+
use Illuminate\Validation\ValidationException;
3543
use LibreNMS\Util\Git;
3644
use Spatie\LaravelIgnition\Facades\Flare;
45+
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
46+
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
47+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
3748
use Throwable;
3849

3950
class ErrorReporting
@@ -79,7 +90,7 @@ public function report(Throwable $e): bool
7990
return true;
8091
}
8192

82-
public function render(Throwable $exception, Request $request): ?Response
93+
public function render(Throwable $exception, Request $request): Response|JsonResponse|null
8394
{
8495
// try to upgrade generic exceptions to more specific ones
8596
if (! config('app.debug')) {
@@ -94,9 +105,136 @@ public function render(Throwable $exception, Request $request): ?Response
94105
}
95106
}
96107

108+
if ($request->is('api/v1/*') || $request->is('api/v1')) {
109+
return self::renderApiException($exception);
110+
}
111+
97112
return null; // use default rendering
98113
}
99114

115+
/**
116+
* Render any exception as a JSON:API errors-array document for v1 API requests.
117+
* Public + static so it can be unit-tested without a full HTTP cycle.
118+
*/
119+
public static function renderApiException(Throwable $e): JsonResponse
120+
{
121+
[$status, $code, $title] = self::classifyException($e);
122+
123+
if ($e instanceof ValidationException) {
124+
$entries = [];
125+
foreach ($e->errors() as $field => $messages) {
126+
foreach ((array) $messages as $message) {
127+
$entries[] = [
128+
'status' => (string) $status,
129+
'code' => $code,
130+
'title' => $title,
131+
'detail' => $message,
132+
'source' => ['pointer' => '/' . ltrim((string) $field, '/')],
133+
];
134+
}
135+
}
136+
if ($entries === []) {
137+
$entries[] = self::buildSingleErrorEntry($e, $status, $code, $title);
138+
}
139+
} else {
140+
$entries = [self::buildSingleErrorEntry($e, $status, $code, $title)];
141+
}
142+
143+
if ($status >= 500 && config('app.debug')) {
144+
$entries[0]['meta'] = [
145+
'exception' => $e::class,
146+
'file' => $e->getFile(),
147+
'line' => $e->getLine(),
148+
'trace' => self::formatTrace($e),
149+
];
150+
}
151+
152+
return response()->json(
153+
['errors' => $entries],
154+
$status,
155+
['Content-Type' => EnforceJsonApi::CONTENT_TYPE],
156+
);
157+
}
158+
159+
/**
160+
* @return array{0:int,1:string,2:string} [status, code, title]
161+
*/
162+
private static function classifyException(Throwable $e): array
163+
{
164+
if ($e instanceof ValidationException) {
165+
return [$e->status, 'validation_failed', 'Validation Failed'];
166+
}
167+
if ($e instanceof AuthenticationException) {
168+
return [401, 'unauthenticated', 'Unauthenticated'];
169+
}
170+
if ($e instanceof AuthorizationException) {
171+
return [403, 'forbidden', 'Forbidden'];
172+
}
173+
if ($e instanceof ModelNotFoundException || $e instanceof RepositoryNotFoundException) {
174+
return [404, 'not_found', 'Not Found'];
175+
}
176+
if ($e instanceof ThrottleRequestsException) {
177+
return [429, 'too_many_requests', 'Too Many Requests'];
178+
}
179+
if ($e instanceof HttpExceptionInterface) {
180+
return self::classifyByStatus($e->getStatusCode());
181+
}
182+
183+
return [500, 'server_error', 'Server Error'];
184+
}
185+
186+
/**
187+
* @return array{0:int,1:string,2:string}
188+
*/
189+
private static function classifyByStatus(int $status): array
190+
{
191+
return match ($status) {
192+
401 => [401, 'unauthenticated', 'Unauthenticated'],
193+
403 => [403, 'forbidden', 'Forbidden'],
194+
404 => [404, 'not_found', 'Not Found'],
195+
405 => [405, 'method_not_allowed', 'Method Not Allowed'],
196+
429 => [429, 'too_many_requests', 'Too Many Requests'],
197+
default => [
198+
$status,
199+
'http_' . $status,
200+
Response::$statusTexts[$status] ?? 'Error',
201+
],
202+
};
203+
}
204+
205+
/**
206+
* @return array<string, mixed>
207+
*/
208+
private static function buildSingleErrorEntry(Throwable $e, int $status, string $code, string $title): array
209+
{
210+
$detail = $e->getMessage();
211+
if ($detail === '' || ($status >= 500 && ! config('app.debug'))) {
212+
$detail = $status >= 500 ? 'An unexpected error occurred.' : $title;
213+
}
214+
215+
return [
216+
'status' => (string) $status,
217+
'code' => $code,
218+
'title' => $title,
219+
'detail' => $detail,
220+
];
221+
}
222+
223+
/**
224+
* @return string[]
225+
*/
226+
private static function formatTrace(Throwable $e): array
227+
{
228+
$frames = [];
229+
foreach (array_slice($e->getTrace(), 0, 10) as $frame) {
230+
$location = ($frame['file'] ?? '?') . ':' . ($frame['line'] ?? '?');
231+
$call = ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? '');
232+
$frames[] = trim($location . ' ' . $call);
233+
}
234+
235+
return $frames;
236+
}
237+
100238
/**
101239
* Checks the state of the config and current install to determine if reporting should be enabled
102240
* The primary factor is the setting reporting.error

app/Http/Middleware/EnforceJsonApi.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ public function handle(Request $request, Closure $next): Response
1515
// Reject request bodies with wrong Content-Type (JSON:API §5.3)
1616
if ($request->getContent() && ! $this->isJsonApi($request->header('Content-Type', ''))) {
1717
return response()->json([
18-
'errors' => [['status' => '415', 'title' => 'Unsupported Media Type']],
18+
'errors' => [[
19+
'status' => '415',
20+
'code' => 'unsupported_media_type',
21+
'title' => 'Unsupported Media Type',
22+
'detail' => 'Request body must be application/vnd.api+json or application/json.',
23+
]],
1924
], 415)->withHeaders(['Content-Type' => self::CONTENT_TYPE]);
2025
}
2126

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php
2+
3+
namespace App\Http\Middleware;
4+
5+
use Binaryk\LaravelRestify\Fields\EagerField;
6+
use Binaryk\LaravelRestify\Restify;
7+
use Closure;
8+
use Illuminate\Http\Request;
9+
use Symfony\Component\HttpFoundation\Response;
10+
11+
/**
12+
* Lets repositories advertise URL-friendly attach/sync/detach segments that
13+
* don't match the underlying Eloquent relation method name.
14+
*
15+
* For URLs like /api/v1/{parent}/{id}/(attach|sync|detach)/{segment}, we look
16+
* up the field on the parent repository whose `attribute` equals `{segment}`
17+
* and, when its `relation` property differs (e.g. attribute='device-groups',
18+
* relation='groups'), inject `viaRelationship` on the request so Restify's
19+
* controllers call `$model->groups()` rather than the literal URL segment.
20+
*
21+
* Effectively decouples the URL slug from the model method, which PHP requires
22+
* to be a valid identifier (no hyphens).
23+
*/
24+
class RestifyAttachRelationResolver
25+
{
26+
public function handle(Request $request, Closure $next): Response
27+
{
28+
if (! preg_match('#^api/v1/([^/]+)/[^/]+/(?:attach|sync|detach)/([^/?]+)#', $request->path(), $m)) {
29+
return $next($request);
30+
}
31+
32+
[$_, $parentSegment, $relatedSegment] = $m;
33+
34+
$repoClass = Restify::repositoryClassForKey($parentSegment);
35+
if (! $repoClass || ! method_exists($repoClass, 'related')) {
36+
return $next($request);
37+
}
38+
39+
foreach ($repoClass::related() as $field) {
40+
if (! $field instanceof EagerField || $field->attribute !== $relatedSegment) {
41+
continue;
42+
}
43+
44+
if (isset($field->relation) && $field->relation !== $field->attribute) {
45+
// Laravel Request's __get checks input first then route params;
46+
// merging the value into the input bag is the simplest way to
47+
// make vendor's `$request->viaRelationship` resolve to the
48+
// model method name we want (e.g. groups()).
49+
$request->merge(['viaRelationship' => $field->relation]);
50+
}
51+
break;
52+
}
53+
54+
return $next($request);
55+
}
56+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Http\Requests\Restify;
4+
5+
use Binaryk\LaravelRestify\Http\Requests\RepositoryAttachRequest;
6+
use Illuminate\Support\Collection;
7+
8+
class AttachRequest extends RepositoryAttachRequest
9+
{
10+
use ResolvesRelatedRepositoryFromField;
11+
12+
public function attachRelatedModels(): Collection
13+
{
14+
return $this->resolveRelatedModels();
15+
}
16+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Http\Requests\Restify;
4+
5+
use Binaryk\LaravelRestify\Http\Requests\RepositoryDetachRequest;
6+
use Illuminate\Support\Collection;
7+
8+
class DetachRequest extends RepositoryDetachRequest
9+
{
10+
use ResolvesRelatedRepositoryFromField;
11+
12+
public function detachRelatedModels(): Collection
13+
{
14+
return $this->resolveRelatedModels();
15+
}
16+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace App\Http\Requests\Restify;
4+
5+
use Binaryk\LaravelRestify\Fields\BelongsToMany;
6+
use Binaryk\LaravelRestify\Fields\EagerField;
7+
use Binaryk\LaravelRestify\Repositories\Repository;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Support\Arr;
10+
use Illuminate\Support\Collection;
11+
12+
/**
13+
* Replaces Restify's default `Restify::repositoryForTable($urlSegment)` lookup
14+
* (which fails whenever the URL segment doesn't equal the related model's
15+
* actual database table name e.g. `attach/device-groups` against the
16+
* `device_groups` table) with a field-driven lookup that uses the parent
17+
* repository's `related()` declaration.
18+
*
19+
* The URL segment is matched to the field's `attribute`. The target repository
20+
* is taken from the field's `repositoryClass`. This works for every attach/
21+
* sync/detach URL regardless of how it differs from the underlying table.
22+
*/
23+
trait ResolvesRelatedRepositoryFromField
24+
{
25+
protected function resolveRelatedModels(): Collection
26+
{
27+
$segment = $this->relatedRepository;
28+
29+
$field = collect($this->repository()::related())
30+
->first(fn ($candidate) => $candidate instanceof EagerField && $candidate->attribute === $segment);
31+
32+
if (! $field instanceof BelongsToMany) {
33+
abort(400, "Missing BelongsToMany relation for [{$segment}] on the parent repository.");
34+
}
35+
36+
/** @var class-string<Repository> $relatedClass */
37+
$relatedClass = $field->repositoryClass;
38+
/** @var Model $model */
39+
$model = app($relatedClass::guessModelClassName());
40+
41+
return collect(Arr::wrap($this->input($segment)))
42+
->map(fn ($id) => $model->newModelQuery()->whereKey($id)->first());
43+
}
44+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
namespace App\Http\Requests\Restify;
4+
5+
use Binaryk\LaravelRestify\Http\Requests\RepositorySyncRequest;
6+
use Illuminate\Support\Collection;
7+
8+
class SyncRequest extends RepositorySyncRequest
9+
{
10+
use ResolvesRelatedRepositoryFromField;
11+
12+
public function syncRelatedModels(): Collection
13+
{
14+
return $this->resolveRelatedModels();
15+
}
16+
}

app/Models/AlertRule.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,14 @@ public function alertOperation(): BelongsTo
189189
return $this->belongsTo(AlertOperation::class, 'alert_operation_id');
190190
}
191191

192+
/**
193+
* @return BelongsToMany<AlertTemplate, $this>
194+
*/
195+
public function templates(): BelongsToMany
196+
{
197+
return $this->belongsToMany(AlertTemplate::class, 'alert_template_map', 'alert_rule_id', 'alert_templates_id');
198+
}
199+
192200
/**
193201
* Backwards-compatible shape: one array entry per segment (same as legacy multi-row operations).
194202
*

0 commit comments

Comments
 (0)