Skip to content

Commit 741152d

Browse files
committed
feat(api): improved API errors with formatted RFC 7807 Problem Details JSON responses (#3830)
1 parent 2aa5f7e commit 741152d

File tree

5 files changed

+534
-61
lines changed

5 files changed

+534
-61
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@ This is a log of major user-visible changes in each phpMyFAQ release.
99
### phpMyFAQ v4.2.0-dev - unreleased
1010

1111
- changed PHP requirement to PHP 8.4 or later (Thorsten)
12+
- added Symfony Router for frontend (Thorsten)
13+
- improved API errors with formatted RFC 7807 Problem Details JSON responses (Thorsten)
1214

1315
### phpMyFAQ v4.1.0-RC.2 - unreleased
1416

1517
- changed PHP requirement to PHP 8.3 or later (Thorsten)
1618
- added configuration to edit robots.txt (Thorsten)
1719
- added configuration to edit llms.txt (Thorsten)
18-
- added Symfony Routing for administration backend (Thorsten)
20+
- added Symfony Router for administration backend (Thorsten)
1921
- added code snippets plugin with syntax highlighting in WYSIWYG editor (Thorsten)
2022
- added an administration view for orphaned FAQs (Thorsten)
2123
- added plugin administration backend (Thorsten)

phpmyfaq/.htaccess

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,27 +100,20 @@ Header set Access-Control-Allow-Headers "Content-Type, Authorization"
100100
RewriteBase /
101101
# Block zip files in content directory
102102
RewriteRule ^content/.*\.zip$ - [F,L]
103-
104103
# Exclude assets from being handled by Symfony Router
105104
RewriteRule ^admin/assets($|/) - [L]
106-
107105
# Error pages
108106
ErrorDocument 404 /404.html
109-
110107
# Administration API
111108
RewriteRule ^admin/api/ admin/api/index.php [L,QSA]
112-
113109
# Administration pages (redirect /admin to /admin/)
114110
RewriteRule ^admin$ admin/ [R=301,L]
115111
RewriteRule ^admin/ admin/index.php [L,QSA]
116-
117112
# API routes (all API endpoints)
118113
RewriteRule ^api/ api/index.php [L,QSA]
119-
120114
# Setup pages
121115
RewriteRule ^setup/ setup/index.php [L,QSA]
122116
RewriteRule ^update$ update/ [R=301,L]
123-
124117
# Front controller: route all other requests to index.php (Symfony Router)
125118
# Skip if the file or directory exists
126119
RewriteCond %{REQUEST_FILENAME} !-f
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/**
4+
* The ProblemDetails class for API error responses
5+
*
6+
* This Source Code Form is subject to the terms of the Mozilla Public License,
7+
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
8+
* obtain one at https://mozilla.org/MPL/2.0/.
9+
*
10+
* @package phpMyFAQ
11+
* @author Thorsten Rinne <[email protected]>
12+
* @copyright 2026 phpMyFAQ Team
13+
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14+
* @link https://www.phpmyfaq.de
15+
* @since 2026-01-03
16+
*/
17+
18+
declare(strict_types=1);
19+
20+
namespace phpMyFAQ\Api;
21+
22+
final readonly class ProblemDetails
23+
{
24+
public function __construct(
25+
public string $type,
26+
public string $title,
27+
public int $status,
28+
public string $detail,
29+
public string $instance,
30+
public ?string $code = null,
31+
public ?array $errors = null, // field-level errors, optional
32+
public ?string $traceId = null, // correlation id, optional
33+
) {
34+
}
35+
36+
public function toArray(): array
37+
{
38+
$data = [
39+
'type' => $this->type,
40+
'title' => $this->title,
41+
'status' => $this->status,
42+
'detail' => $this->detail,
43+
'instance' => $this->instance,
44+
];
45+
46+
if ($this->code !== null) {
47+
$data['code'] = $this->code;
48+
}
49+
if ($this->errors !== null) {
50+
$data['errors'] = $this->errors;
51+
}
52+
if ($this->traceId !== null) {
53+
$data['traceId'] = $this->traceId;
54+
}
55+
56+
return $data;
57+
}
58+
}

phpmyfaq/src/phpMyFAQ/Application.php

Lines changed: 117 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
namespace phpMyFAQ;
2121

22+
use phpMyFAQ\Api\ProblemDetails;
2223
use phpMyFAQ\Controller\Exception\ForbiddenException;
2324
use phpMyFAQ\Core\Exception;
2425
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -139,15 +140,14 @@ private function handleRequest(
139140
$response->setStatusCode(Response::HTTP_OK);
140141
$response = call_user_func_array($controller, $arguments);
141142
} catch (ResourceNotFoundException $exception) {
142-
// For API requests, return simple text/JSON response
143+
// For API requests, return RFC 7807 JSON response
143144
if ($this->isApiContext) {
144-
$message = Environment::isDebugMode()
145-
? $this->formatExceptionMessage(
146-
template: 'Not Found: :message at line :line at :file',
147-
exception: $exception,
148-
)
149-
: 'Not Found';
150-
$response = new Response(content: $message, status: Response::HTTP_NOT_FOUND);
145+
$response = $this->createProblemDetailsResponse(
146+
request: $request,
147+
status: Response::HTTP_NOT_FOUND,
148+
exception: $exception,
149+
defaultDetail: 'The requested resource was not found.',
150+
);
151151
} else {
152152
// For web requests, forward to the PageNotFoundController
153153
try {
@@ -170,31 +170,51 @@ private function handleRequest(
170170
$response = new Response(content: $message, status: Response::HTTP_NOT_FOUND);
171171
}
172172
}
173-
} catch (UnauthorizedHttpException) {
174-
$response = new RedirectResponse(url: './login');
175-
if (str_contains(haystack: $urlMatcher->getContext()->getBaseUrl(), needle: '/api')) {
176-
$response = new Response(
177-
content: json_encode(value: ['error' => 'Unauthorized access']),
173+
} catch (UnauthorizedHttpException $exception) {
174+
if ($this->isApiContext) {
175+
$response = $this->createProblemDetailsResponse(
176+
request: $request,
178177
status: Response::HTTP_UNAUTHORIZED,
179-
headers: ['Content-Type' => 'application/json'],
178+
exception: $exception,
179+
defaultDetail: 'Unauthorized access.',
180180
);
181+
} else {
182+
$response = new RedirectResponse(url: './login');
181183
}
182184
} catch (ForbiddenException $exception) {
183-
$message = Environment::isDebugMode()
184-
? $this->formatExceptionMessage(
185-
template: 'An error occurred: :message at line :line at :file',
185+
if ($this->isApiContext) {
186+
$response = $this->createProblemDetailsResponse(
187+
request: $request,
188+
status: Response::HTTP_FORBIDDEN,
186189
exception: $exception,
187-
)
188-
: 'Bad Request';
189-
$response = new Response(content: $message, status: Response::HTTP_FORBIDDEN);
190+
defaultDetail: 'Access to this resource is forbidden.',
191+
);
192+
} else {
193+
$message = Environment::isDebugMode()
194+
? $this->formatExceptionMessage(
195+
template: 'An error occurred: :message at line :line at :file',
196+
exception: $exception,
197+
)
198+
: 'Forbidden';
199+
$response = new Response(content: $message, status: Response::HTTP_FORBIDDEN);
200+
}
190201
} catch (BadRequestException $exception) {
191-
$message = Environment::isDebugMode()
192-
? $this->formatExceptionMessage(
193-
template: 'An error occurred: :message at line :line at :file',
202+
if ($this->isApiContext) {
203+
$response = $this->createProblemDetailsResponse(
204+
request: $request,
205+
status: Response::HTTP_BAD_REQUEST,
194206
exception: $exception,
195-
)
196-
: 'Bad Request';
197-
$response = new Response(content: $message, status: Response::HTTP_BAD_REQUEST);
207+
defaultDetail: 'The request could not be understood or was missing required parameters.',
208+
);
209+
} else {
210+
$message = Environment::isDebugMode()
211+
? $this->formatExceptionMessage(
212+
template: 'An error occurred: :message at line :line at :file',
213+
exception: $exception,
214+
)
215+
: 'Bad Request';
216+
$response = new Response(content: $message, status: Response::HTTP_BAD_REQUEST);
217+
}
198218
} catch (Throwable $exception) {
199219
// Log the error for debugging
200220
error_log(sprintf(
@@ -204,33 +224,22 @@ private function handleRequest(
204224
$exception->getLine(),
205225
));
206226

207-
$message = Environment::isDebugMode()
208-
? $this->formatExceptionMessage(
209-
template: 'Internal Server Error: :message at line :line at :file',
227+
if ($this->isApiContext) {
228+
$response = $this->createProblemDetailsResponse(
229+
request: $request,
230+
status: Response::HTTP_INTERNAL_SERVER_ERROR,
210231
exception: $exception,
211-
)
212-
: 'Internal Server Error';
213-
214-
// Return JSON response for API requests
215-
if (str_contains(haystack: $urlMatcher->getContext()->getBaseUrl(), needle: '/api')) {
216-
$content = Environment::isDebugMode()
217-
? json_encode(value: [
218-
'error' => 'Internal Server Error',
219-
'message' => $exception->getMessage(),
220-
'file' => $exception->getFile(),
221-
'line' => $exception->getLine(),
222-
])
223-
: json_encode(value: ['error' => 'Internal Server Error']);
224-
225-
$response = new Response(content: $content, status: Response::HTTP_INTERNAL_SERVER_ERROR, headers: [
226-
'Content-Type' => 'application/json',
227-
]);
228-
229-
$response->send();
230-
return;
232+
defaultDetail: 'An unexpected error occurred while processing your request.',
233+
);
234+
} else {
235+
$message = Environment::isDebugMode()
236+
? $this->formatExceptionMessage(
237+
template: 'Internal Server Error: :message at line :line at :file',
238+
exception: $exception,
239+
)
240+
: 'Internal Server Error';
241+
$response = new Response(content: $message, status: Response::HTTP_INTERNAL_SERVER_ERROR);
231242
}
232-
233-
$response = new Response(content: $message, status: Response::HTTP_INTERNAL_SERVER_ERROR);
234243
}
235244

236245
$response->send();
@@ -247,4 +256,61 @@ private function formatExceptionMessage(string $template, Throwable $exception):
247256
':file' => $exception->getFile(),
248257
]);
249258
}
259+
260+
/**
261+
* Creates a ProblemDetails response for API errors.
262+
*/
263+
private function createProblemDetailsResponse(
264+
Request $request,
265+
int $status,
266+
Throwable $exception,
267+
string $defaultDetail,
268+
): Response {
269+
$configuration = $this->container->get(id: 'phpmyfaq.configuration');
270+
$baseUrl = rtrim($configuration->getDefaultUrl(), '/');
271+
272+
$type = match ($status) {
273+
Response::HTTP_BAD_REQUEST => $baseUrl . '/problems/bad-request',
274+
Response::HTTP_UNAUTHORIZED => $baseUrl . '/problems/unauthorized',
275+
Response::HTTP_FORBIDDEN => $baseUrl . '/problems/forbidden',
276+
Response::HTTP_NOT_FOUND => $baseUrl . '/problems/not-found',
277+
Response::HTTP_CONFLICT => $baseUrl . '/problems/conflict',
278+
Response::HTTP_UNPROCESSABLE_ENTITY => $baseUrl . '/problems/validation-error',
279+
Response::HTTP_TOO_MANY_REQUESTS => $baseUrl . '/problems/rate-limited',
280+
Response::HTTP_INTERNAL_SERVER_ERROR => $baseUrl . '/problems/internal-server-error',
281+
default => $baseUrl . '/problems/http-error',
282+
};
283+
284+
$title = match ($status) {
285+
Response::HTTP_BAD_REQUEST => 'Bad Request',
286+
Response::HTTP_UNAUTHORIZED => 'Unauthorized',
287+
Response::HTTP_FORBIDDEN => 'Forbidden',
288+
Response::HTTP_NOT_FOUND => 'Resource not found',
289+
Response::HTTP_CONFLICT => 'Conflict',
290+
Response::HTTP_UNPROCESSABLE_ENTITY => 'Validation failed',
291+
Response::HTTP_TOO_MANY_REQUESTS => 'Too many requests',
292+
Response::HTTP_INTERNAL_SERVER_ERROR => 'Internal Server Error',
293+
default => 'HTTP error',
294+
};
295+
296+
$detail = Environment::isDebugMode()
297+
? $exception->getMessage() . ' at line ' . $exception->getLine() . ' in ' . $exception->getFile()
298+
: $defaultDetail;
299+
300+
$problemDetails = new ProblemDetails(
301+
type: $type,
302+
title: $title,
303+
status: $status,
304+
detail: $detail,
305+
instance: $request->getPathInfo(),
306+
);
307+
308+
$response = new Response(
309+
content: json_encode($problemDetails->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES),
310+
status: $status,
311+
);
312+
$response->headers->set('Content-Type', 'application/problem+json');
313+
314+
return $response;
315+
}
250316
}

0 commit comments

Comments
 (0)