2727namespace App \Exceptions ;
2828
2929use 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 ;
3034use Illuminate \Cache \RateLimiting \Limit ;
35+ use Illuminate \Database \Eloquent \ModelNotFoundException ;
3136use Illuminate \Foundation \Configuration \Exceptions ;
37+ use Illuminate \Http \Exceptions \ThrottleRequestsException ;
38+ use Illuminate \Http \JsonResponse ;
3239use Illuminate \Http \Request ;
3340use Illuminate \Http \Response ;
3441use Illuminate \Support \Str ;
42+ use Illuminate \Validation \ValidationException ;
3543use LibreNMS \Util \Git ;
3644use 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 ;
3748use Throwable ;
3849
3950class 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
0 commit comments