diff --git a/framework/Exceptions/messages/messages.txt b/framework/Exceptions/messages/messages.txt index 183984dae..8dd3bb791 100644 --- a/framework/Exceptions/messages/messages.txt +++ b/framework/Exceptions/messages/messages.txt @@ -176,6 +176,18 @@ pageserviceconf_parameter_invalid = element must have an "id" attri pageserviceconf_page_invalid = element must have an "id" attribute in page directory configuration file '{0}'. pageserviceconf_includefile_required = Page configuration element must have a "file" attribute. +restservice_pattern_required = TRestService resource configuration requires a 'pattern' attribute. +restservice_class_required = TRestService resource configuration requires a 'class' attribute. +restservice_resource_invalid = TRestService resource class '{0}' must extend Prado\Web\Services\Rest\TRestResource. +restservice_http_error = HTTP {0} {1} +restgroup_file_not_found = TRestService group file '{0}' could not be found (tried .php and .xml extensions). +restconfig_file_not_found = TRestService config file '{0}' could not be found (tried .php and .xml extensions). +restservice_cors_credentials_wildcard = TRestService.AllowOrigin must be an explicit origin (not '*') when AllowCredentials is enabled. +restservice_param_name_invalid = TRestService route parameter name '{0}' in pattern '{1}' must match [A-Za-z_][A-Za-z0-9_]*. +restservice_param_name_duplicate = TRestService route pattern '{0}' uses a duplicate parameter name. +restresource_rule_unknown = TRestResource validation rule '{0}' for field '{1}' is not recognized. +restresource_invalid_header = TRestResource response header '{0}' has an invalid name or a value containing CR/LF. + basebehavior_cannot_setname_with_owner = The TBaseBehavior.Name "{0}" cannot be changed (to "{1}") after the behavior is attached. basebehavior_sync_no_owner = The TBaseBehavior "{0}" has no owner and cannot synchronize event handlers on a component without owners. basebehavior_sync_not_owner = The TBaseBehavior "{0}" cannot synchronize event handlers on an object that is not the owner. diff --git a/framework/Web/Services/Rest/TRestException.php b/framework/Web/Services/Rest/TRestException.php new file mode 100644 index 000000000..844c4b92e --- /dev/null +++ b/framework/Web/Services/Rest/TRestException.php @@ -0,0 +1,301 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Web\Services\Rest; + +use Prado\Exceptions\THttpException; + +/** + * TRestException class + * + * TRestException represents an HTTP error that has occurred during REST request + * processing. It carries an HTTP status code, a human-readable title, an optional + * detail message, and an optional structured array of field-level validation errors. + * + * The error payload is inspired by RFC 7807 (Problem Details for HTTP APIs) and is + * serialized as JSON by {@see TRestService} when returned to the client. + * + * Use the static factory methods for the most common HTTP error conditions: + * ```php + * throw TRestException::notFound('User not found.'); + * throw TRestException::unprocessable(['email' => ['The email field is required.']]); + * ``` + * + * The {@see toArray()} method produces the JSON-serializable error payload: + * ```json + * { + * "status": 422, + * "title": "Unprocessable Entity", + * "detail": "The given data was invalid.", + * "errors": { "email": ["The email field is required."] } + * } + * ``` + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TRestException extends THttpException +{ + /** + * Standard HTTP status reason phrases. + */ + private static array $HTTP_TITLES = [ + 400 => 'Bad Request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 409 => 'Conflict', + 415 => 'Unsupported Media Type', + 422 => 'Unprocessable Entity', + 429 => 'Too Many Requests', + 500 => 'Internal Server Error', + 503 => 'Service Unavailable', + ]; + + /** + * @var string Short, human-readable summary of the problem. + */ + private string $_title; + + /** + * @var string Human-readable explanation specific to this occurrence. + */ + private string $_detail; + + /** + * @var array Field-level validation errors keyed by field name. + */ + private array $_errors; + + /** + * Constructor. Falls back to the standard reason phrase when `$title` + * is empty, and forces `Exception::$code` to equal `$statusCode` so that + * `getCode() === getStatusCode()` and PHPUnit's `expectExceptionCode()` + * works as expected. + * @param int $statusCode HTTP status code (e.g. 404, 422). + * @param string $title Short problem title; empty uses the reason phrase. + * @param string $detail Human-readable detail message. + * @param array $errors Field-level validation errors keyed by field name. + */ + public function __construct(int $statusCode, string $title = '', string $detail = '', array $errors = []) + { + $this->setTitleDirect($title !== '' ? $title : (self::$HTTP_TITLES[$statusCode] ?? 'Error')); + $this->setDetailDirect($detail); + $this->setErrorsDirect($errors); + parent::__construct($statusCode, 'restservice_http_error', $statusCode, $this->getTitleDirect()); + // THttpException uses old-style TException construction, so PHP's Exception::$code + // stays 0. Set it explicitly so getCode() === getStatusCode() and PHPUnit's + // expectExceptionCode() works as expected. + $this->code = $statusCode; + } + + // ── Direct Accessors (UAP-SE) ────────────────────────────────────────────── + + /** + * @return string Stored title value. + */ + protected function getTitleDirect(): string + { + return $this->_title; + } + + /** + * @param string $value Title to store. + */ + protected function setTitleDirect(string $value): void + { + $this->_title = $value; + } + + /** + * @return string Stored detail message. + */ + protected function getDetailDirect(): string + { + return $this->_detail; + } + + /** + * @param string $value Detail message to store. + */ + protected function setDetailDirect(string $value): void + { + $this->_detail = $value; + } + + /** + * @return array Stored field-level errors. + */ + protected function getErrorsDirect(): array + { + return $this->_errors; + } + + /** + * @param array $value Field-level errors to store. + */ + protected function setErrorsDirect(array $value): void + { + $this->_errors = $value; + } + + // ── Public accessors ────────────────────────────────────────────────────── + + /** + * @return string Short problem title. + */ + public function getTitle(): string + { + return $this->getTitleDirect(); + } + + /** + * @return string Human-readable detail message, empty string if none. + */ + public function getDetail(): string + { + return $this->getDetailDirect(); + } + + /** + * @return array Field-level validation errors, empty array if none. + */ + public function getErrors(): array + { + return $this->getErrorsDirect(); + } + + /** + * Returns the exception as an array suitable for JSON serialization. + * The `status` and `title` keys are always present. The `detail` key is + * included only when non-empty. The `errors` key is included only when + * the errors array is non-empty. + * @return array RFC 7807-inspired problem detail array. + */ + public function toArray(): array + { + $result = [ + 'status' => $this->getStatusCode(), + 'title' => $this->getTitle(), + ]; + if ($this->getDetail() !== '') { + $result['detail'] = $this->getDetail(); + } + if ($this->getErrors() !== []) { + $result['errors'] = $this->getErrors(); + } + return $result; + } + + // ── Static factory methods ───────────────────────────────────────────────── + + /** + * Creates a 400 Bad Request exception. + * @param string $detail Optional detail message. + * @return self + */ + public static function badRequest(string $detail = ''): self + { + return new self(400, '', $detail); + } + + /** + * Creates a 401 Unauthorized exception. + * @param string $detail Optional detail message. + * @return self + */ + public static function unauthorized(string $detail = ''): self + { + return new self(401, '', $detail); + } + + /** + * Creates a 403 Forbidden exception. + * @param string $detail Optional detail message. + * @return self + */ + public static function forbidden(string $detail = ''): self + { + return new self(403, '', $detail); + } + + /** + * Creates a 404 Not Found exception. + * @param string $detail Optional detail message. + * @return self + */ + public static function notFound(string $detail = ''): self + { + return new self(404, '', $detail); + } + + /** + * Creates a 405 Method Not Allowed exception. + * @param string $detail Optional detail message. + * @return self + */ + public static function methodNotAllowed(string $detail = ''): self + { + return new self(405, '', $detail); + } + + /** + * Creates a 409 Conflict exception. + * @param string $detail Optional detail message. + * @return self + */ + public static function conflict(string $detail = ''): self + { + return new self(409, '', $detail); + } + + /** + * Creates a 415 Unsupported Media Type exception. + * @param string $detail Optional detail message. + * @return self + */ + public static function unsupportedMediaType(string $detail = ''): self + { + return new self(415, '', $detail); + } + + /** + * Creates a 422 Unprocessable Entity exception with optional field errors. + * @param array $errors Field-level validation errors keyed by field name, + * where each value is an array of error message strings. + * @param string $detail Optional detail message. + * @return self + */ + public static function unprocessable(array $errors = [], string $detail = ''): self + { + return new self(422, '', $detail, $errors); + } + + /** + * Creates a 429 Too Many Requests exception. + * @param string $detail Optional detail message. + * @return self + */ + public static function tooManyRequests(string $detail = ''): self + { + return new self(429, '', $detail); + } + + /** + * Creates a 500 Internal Server Error exception. + * @param string $detail Optional detail message. + * @return self + */ + public static function internalError(string $detail = ''): self + { + return new self(500, '', $detail); + } +} diff --git a/framework/Web/Services/Rest/TRestPagination.php b/framework/Web/Services/Rest/TRestPagination.php new file mode 100644 index 000000000..224a85ab1 --- /dev/null +++ b/framework/Web/Services/Rest/TRestPagination.php @@ -0,0 +1,311 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Web\Services\Rest; + +use Prado\Prado; +use Prado\TApplicationComponent; +use Prado\Web\THttpRequest; + +/** + * TRestPagination class. + * + * TRestPagination is a stateless pagination helper for REST API list endpoints. + * It reads the `page` and `per_page` request parameters from the current request + * (via {@see \Prado\Web\THttpRequest::itemAt()}, which covers query-string, route, + * and form values) and exposes SQL-friendly `offset` and `limit` values. + * + * ## Usage in a TRestResource + * + * ```php + * public function doIndex(): array + * { + * $pagination = TRestPagination::fromRequest(); + * + * $users = User::findAll( + * offset: $pagination->getOffset(), + * limit: $pagination->getLimit(), + * ); + * $total = User::count(); + * + * return $pagination->paginate($users, $total); + * } + * ``` + * + * The response produced by {@see paginate()} follows the format: + * ```json + * { + * "data": [ ... ], + * "meta": { + * "total": 150, + * "per_page": 20, + * "current_page": 2, + * "last_page": 8, + * "from": 21, + * "to": 40 + * } + * } + * ``` + * + * ## Request Parameters + * + * | Parameter | Description | Default | + * |------------|-------------|---------| + * | `page` | 1-based page number (clamped to at least 1) | 1 | + * | `per_page` | Items per page (clamped to `[1, maxPerPage]`) | 20 | + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TRestPagination extends TApplicationComponent +{ + /** + * @var int Current page number (1-based). + */ + private int $_page; + + /** + * @var int Number of items per page. + */ + private int $_perPage; + + /** + * @var int Maximum allowed value for per_page. + */ + private int $_maxPerPage; + + /** + * Constructor. Clamps `$page` and `$perPage` to at least 1, and caps + * `$perPage` at `$maxPerPage`. + * @param int $page 1-based page number; values below 1 become 1. + * @param int $perPage Items per page; values below 1 become 1, values + * above `$maxPerPage` become `$maxPerPage`. + * @param int $maxPerPage Maximum allowed `per_page` value. Defaults to 100. + */ + public function __construct(int $page = 1, int $perPage = 20, int $maxPerPage = 100) + { + parent::__construct(); + $this->setMaxPerPage($maxPerPage); // must be set before setPerPage — see setPerPage() + $this->setPerPage($perPage); + $this->setPage($page); + } + + /** + * Creates a TRestPagination instance from the current HTTP request. + * + * Reads the `page` and `per_page` request parameters. The `page` value is + * clamped to at least 1, and `per_page` is clamped to the range + * `[1, $maxPerPage]`. + * + * @param ?THttpRequest $request Request to read parameters from. + * Defaults to the application's current request. + * @param int $defaultPerPage Default items-per-page when `per_page` is absent. Defaults to 20. + * @param int $maxPerPage Maximum allowed per-page value. Defaults to 100. + * @return self The pagination helper built from the request parameters. + */ + public static function fromRequest(?THttpRequest $request = null, int $defaultPerPage = 20, int $maxPerPage = 100): self + { + if ($request === null) { + $request = Prado::getApplication()->getRequest(); + } + + $page = max(1, (int) ($request->itemAt('page') ?? 1)); + $perPage = max(1, (int) ($request->itemAt('per_page') ?? $defaultPerPage)); + // Constructor calls setMaxPerPage, setPerPage, setPage — all clamping is applied there + return new self($page, $perPage, $maxPerPage); + } + + // ── Direct Accessors (UAP-SE) ────────────────────────────────────────────── + + /** + * @return int Stored current page number. + */ + protected function getPageDirect(): int + { + return $this->_page; + } + + /** + * @param int $value Page number to store (raw, no clamping). + */ + protected function setPageDirect(int $value): void + { + $this->_page = $value; + } + + /** + * @return int Stored per-page count. + */ + protected function getPerPageDirect(): int + { + return $this->_perPage; + } + + /** + * @param int $value Per-page count to store (raw, no clamping). + */ + protected function setPerPageDirect(int $value): void + { + $this->_perPage = $value; + } + + /** + * @return int Stored maximum per-page value. + */ + protected function getMaxPerPageDirect(): int + { + return $this->_maxPerPage; + } + + /** + * @param int $value Maximum per-page value to store (raw, no clamping). + */ + protected function setMaxPerPageDirect(int $value): void + { + $this->_maxPerPage = $value; + } + + // ── Accessors ────────────────────────────────────────────────────────────── + + /** + * @return int Current page number (1-based). + */ + public function getPage(): int + { + return $this->getPageDirect(); + } + + /** + * Sets the current page number. Values below 1 are clamped to 1, and the + * value is capped so that {@see getOffset()} cannot overflow `PHP_INT_MAX` + * for the current per-page size. + * @param int $value 1-based page number. + */ + protected function setPage(int $value): void + { + $maxPage = intdiv(PHP_INT_MAX, max(1, $this->getPerPageDirect())) + 1; + $this->setPageDirect(min($maxPage, max(1, $value))); + } + + /** + * @return int Items per page. + */ + public function getPerPage(): int + { + return $this->getPerPageDirect(); + } + + /** + * Sets the per-page count. Values below 1 are clamped to 1. + * Values above {@see getMaxPerPage()} are clamped to the maximum. + * {@see setMaxPerPage()} must be called before this method. + * @param int $value Items per page. + */ + protected function setPerPage(int $value): void + { + $this->setPerPageDirect(min($this->getMaxPerPageDirect(), max(1, $value))); + } + + /** + * @return int Maximum allowed per-page value. + */ + public function getMaxPerPage(): int + { + return $this->getMaxPerPageDirect(); + } + + /** + * Sets the maximum allowed per-page value. Values below 1 are clamped to 1. + * @param int $value Maximum per-page value. + */ + protected function setMaxPerPage(int $value): void + { + $this->setMaxPerPageDirect(max(1, $value)); + } + + /** + * Returns the SQL `OFFSET` value for the current page. + * + * The product is guarded against integer overflow: if `(page - 1) * perPage` + * exceeds `PHP_INT_MAX` (which PHP would promote to a float), `PHP_INT_MAX` + * is returned so the declared `int` return type holds. + * + * @return int Zero-based row offset. + */ + public function getOffset(): int + { + $offset = ($this->getPage() - 1) * $this->getPerPage(); + return is_int($offset) ? $offset : PHP_INT_MAX; + } + + /** + * Returns the SQL `LIMIT` value for the current page. + * Equivalent to {@see getPerPage()}. + * @return int Row count limit. + */ + public function getLimit(): int + { + return $this->getPerPage(); + } + + // ── Response helpers ─────────────────────────────────────────────────────── + + /** + * Returns a pagination meta array for the given total item count. + * + * ```json + * { + * "total": 150, + * "per_page": 20, + * "current_page": 2, + * "last_page": 8, + * "from": 21, + * "to": 40 + * } + * ``` + * + * `from` and `to` are `null` when `$total` is 0. + * + * @param int $total Total number of items across all pages. + * @return array Pagination metadata. + */ + public function toMeta(int $total): array + { + $lastPage = max(1, (int) ceil($total / $this->getPerPage())); + + return [ + 'total' => $total, + 'per_page' => $this->getPerPage(), + 'current_page' => $this->getPage(), + 'last_page' => $lastPage, + 'from' => $total > 0 ? $this->getOffset() + 1 : null, + 'to' => $total > 0 ? min($this->getOffset() + $this->getPerPage(), $total) : null, + ]; + } + + /** + * Wraps a page of data with pagination metadata. + * + * Returns an array with two keys: `data` containing the items and `meta` + * containing the result of {@see toMeta()}. This is the standard response + * envelope for paginated list endpoints. + * + * @param array $data Items for the current page. + * @param int $total Total number of items across all pages. + * @return array Paginated response envelope. + */ + public function paginate(array $data, int $total): array + { + return [ + 'data' => $data, + 'meta' => $this->toMeta($total), + ]; + } +} diff --git a/framework/Web/Services/Rest/TRestResource.php b/framework/Web/Services/Rest/TRestResource.php new file mode 100644 index 000000000..482c0b32c --- /dev/null +++ b/framework/Web/Services/Rest/TRestResource.php @@ -0,0 +1,849 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Web\Services\Rest; + +use Prado\Exceptions\TConfigurationException; +use Prado\Exceptions\TInvalidDataValueException; +use Prado\TApplicationComponent; + +/** + * TRestResource class + * + * TRestResource is the abstract base class for all REST resource handlers used with + * {@see TRestService}. Each subclass represents one or more URL patterns and implements + * the HTTP verb methods it supports. + * + * ## Convention Methods + * + * Override only the verbs your resource supports. Any convention method that is + * **not** declared on the concrete subclass automatically responds with + * `405 Method Not Allowed` (handled via `__call`). + * + * | Method | Verb | Route type | + * |---------------|--------|--------------| + * | `doIndex()` | GET | collection | + * | `doStore()` | POST | collection | + * | `doShow()` | GET | item | + * | `doUpdate()` | PUT | item | + * | `doPatch()` | PATCH | item | + * | `doDestroy()` | DELETE | item | + * + * Because the base class uses `__call` rather than typed stubs, subclasses are + * free to declare any parameter signature they need. Path parameters are injected + * by name via PHP reflection — declare them as method parameters matching the + * `{name}` placeholders in the route pattern: + * ```php + * // Route: users/{userId}/posts/{id} + * public function doShow(string $userId, string $id): array { ... } + * ``` + * + * ## Status Helpers + * + * Use the protected helpers to set non-200 success codes before returning: + * ```php + * public function doStore(): array + * { + * $data = $this->validateBody(['name' => 'required|string']); + * return $this->created(MyRecord::create($data)->toArray()); + * } + * + * public function doDestroy(string $id): void + * { + * MyRecord::delete($id); + * $this->noContent(); + * } + * ``` + * + * ## Authentication + * + * Override {@see authorize()} to enforce any auth requirement. It is called before + * dispatch and receives the `do`-prefixed method name (e.g. `'doShow'`, `'doStore'`). + * It may throw {@see TRestException} to abort the request: + * ```php + * public function authorize(string $method): void + * { + * if ($this->getApplication()->getUser()->getIsGuest()) { + * $this->unauthorized('Authentication required.'); + * } + * } + * ``` + * + * ## Validation + * + * {@see validateBody()} validates the parsed request body against a rule set and + * returns only the declared fields, or throws `422 Unprocessable Entity`: + * ```php + * $data = $this->validateBody([ + * 'email' => 'required|email', + * 'name' => 'required|string|max:255', + * 'age' => 'nullable|integer|min:0|max:150', + * ]); + * ``` + * + * Supported rules: `required`, `nullable`, `string`, `integer`, `float`, `numeric`, + * `boolean`, `bool`, `array`, `email`, `url`, `min:N`, `max:N`, `in:a,b,c`. + * + * ## Example + * + * The following resource handles both the `/api/users` collection and the + * `/api/users/{id}` item route. Register both patterns in `application.xml`: + * ```xml + * + * + * + * + * ``` + * + * ```php + * namespace App\Api; + * + * use Prado\Web\Services\Rest\TRestResource; + * + * class UsersResource extends TRestResource + * { + * // Require a signed-in user for every mutating verb. + * public function authorize(string $method): void + * { + * if (!in_array($method, ['doIndex', 'doShow'], true)) { + * if ($this->getApplication()->getUser()->getIsGuest()) { + * $this->unauthorized('Authentication required.'); + * } + * } + * } + * + * // GET /api/users — supports ?role= query filter + * public function doIndex(): array + * { + * $role = $this->query('role'); + * return UserDao::findAll($role ? ['role' => $role] : []); + * } + * + * // GET /api/users/{id} + * public function doShow(string $id): array + * { + * return UserDao::find((int) $id) + * ?? $this->notFound("User {$id} not found."); + * } + * + * // POST /api/users + * public function doStore(): array + * { + * $data = $this->validateBody([ + * 'name' => 'required|string|max:255', + * 'email' => 'required|email', + * 'role' => 'nullable|string|in:admin,editor,viewer', + * ]); + * $user = UserDao::create($data); + * $this->header('Location', '/api/users/' . $user['id']); + * return $this->created($user); + * } + * + * // PUT /api/users/{id} — full replacement + * public function doUpdate(string $id): array + * { + * UserDao::find((int) $id) ?? $this->notFound("User {$id} not found."); + * $data = $this->validateBody([ + * 'name' => 'required|string|max:255', + * 'email' => 'required|email', + * 'role' => 'required|string|in:admin,editor,viewer', + * ]); + * return UserDao::update((int) $id, $data); + * } + * + * // PATCH /api/users/{id} — partial update; only supplied fields are changed + * public function doPatch(string $id): array + * { + * UserDao::find((int) $id) ?? $this->notFound("User {$id} not found."); + * $data = $this->validate($this->only(['name', 'email', 'role']), [ + * 'name' => 'nullable|string|max:255', + * 'email' => 'nullable|email', + * 'role' => 'nullable|string|in:admin,editor,viewer', + * ]); + * return UserDao::update((int) $id, array_filter($data, fn($v) => $v !== null)); + * } + * + * // DELETE /api/users/{id} + * public function doDestroy(string $id): void + * { + * UserDao::find((int) $id) ?? $this->notFound("User {$id} not found."); + * UserDao::delete((int) $id); + * $this->noContent(); + * } + * } + * ``` + * + * @author Brad Anderson + * @since 4.4.0 + */ +abstract class TRestResource extends TApplicationComponent +{ + /** + * @var array Path parameters extracted from the matched route pattern. + */ + private array $_pathParameters = []; + + /** + * @var int HTTP status code for the response. Defaults to 200. + */ + private int $_statusCode = 200; + + /** + * @var array Additional response headers as name => value pairs. + */ + private array $_responseHeaders = []; + + /** + * @var ?array Lazily parsed request body. Null until first access. + */ + private ?array $_parsedBody = null; + + // ── Convention methods ───────────────────────────────────────────────────── + + /** + * Catches calls to the six REST convention methods that are not overridden + * by the concrete resource subclass. + * + * The six convention method names are: `doIndex`, `doShow`, `doStore`, + * `doUpdate`, `doPatch`, `doDestroy`. Calling any of them on an instance + * that has not overridden the method throws a `405 Method Not Allowed` + * exception. + * + * Using `__call` instead of concrete stub methods avoids PHP's LSP + * signature-compatibility requirement, which would otherwise prevent + * subclasses from adding typed path-parameter arguments (e.g., + * `doShow(string $id): array`). + * + * @param mixed $name Method name called. + * @param mixed $args Arguments passed by the caller (unused). + * @throws TRestException 405 when $name is a known convention method. + * @throws \BadMethodCallException for any other undefined method. + * @return never + */ + public function __call(mixed $name, mixed $args): never + { + if (in_array($name, ['doIndex', 'doShow', 'doStore', 'doUpdate', 'doPatch', 'doDestroy'], true)) { + throw TRestException::methodNotAllowed(); + } + throw new \BadMethodCallException('Call to undefined method ' . static::class . '::' . $name . '()'); + } + + // ── Auth lifecycle ───────────────────────────────────────────────────────── + + /** + * Authorization hook called by {@see TRestService} before dispatch. + * + * Override to enforce authentication or authorization. The `$method` + * parameter is the `do`-prefixed dispatch method name (e.g. `'doShow'`, + * `'doStore'`). Throw a {@see TRestException} to abort the request: + * ```php + * public function authorize(string $method): void + * { + * if ($this->getApplication()->getUser()->getIsGuest()) { + * $this->unauthorized('Authentication required.'); + * } + * } + * ``` + * @param string $method The dispatch method name (e.g., `'doShow'`, `'doStore'`). + */ + public function authorize(string $method): void + { + } + + // ── Direct Accessors (UAP-SE) ────────────────────────────────────────────── + + /** + * @return array Stored path parameters. + */ + protected function getPathParametersDirect(): array + { + return $this->_pathParameters; + } + + /** + * @param array $value Path parameters to store. + */ + protected function setPathParametersDirect(array $value): void + { + $this->_pathParameters = $value; + } + + /** + * @return int Stored HTTP status code. + */ + protected function getStatusCodeDirect(): int + { + return $this->_statusCode; + } + + /** + * @param int $value HTTP status code to store. + */ + protected function setStatusCodeDirect(int $value): void + { + $this->_statusCode = $value; + } + + /** + * @return array Stored response headers. + */ + protected function getResponseHeadersDirect(): array + { + return $this->_responseHeaders; + } + + /** + * @param array $value Response headers to store. + */ + protected function setResponseHeadersDirect(array $value): void + { + $this->_responseHeaders = $value; + } + + /** + * @return ?array Stored parsed body, or null if not yet parsed. + */ + protected function getParsedBodyDirect(): ?array + { + return $this->_parsedBody; + } + + /** + * @param ?array $value Parsed body to store, or null to clear. + */ + protected function setParsedBodyDirect(?array $value): void + { + $this->_parsedBody = $value; + } + + // ── Internal accessors used by TRestService ──────────────────────────────── + + /** + * @return int HTTP status code to send with the response. + */ + public function getStatusCode(): int + { + return $this->getStatusCodeDirect(); + } + + /** + * Sets the HTTP status code for the response. + * Use the semantic helpers {@see created()}, {@see accepted()}, and + * {@see noContent()} in resource methods rather than calling this directly. + * @param int $value HTTP status code. + */ + protected function setStatusCode(int $value): void + { + $this->setStatusCodeDirect($value); + } + + /** + * @return array Additional response headers as name => value pairs. + */ + public function getResponseHeaders(): array + { + return $this->getResponseHeadersDirect(); + } + + /** + * Adds a single response header. + * Called internally by {@see header()}. + * @param string $name Header name. + * @param string $value Header value. + */ + protected function addResponseHeader(string $name, string $value): void + { + if ($name === '' || preg_match('/[^!#$%&\'*+\-.^_`|~0-9A-Za-z]/', $name) || preg_match('/[\r\n]/', $value)) { + throw new TInvalidDataValueException('restresource_invalid_header', $name); + } + $headers = $this->getResponseHeadersDirect(); + $headers[$name] = $value; + $this->setResponseHeadersDirect($headers); + } + + /** + * Sets the extracted path parameters. + * This method is called by {@see TRestService} before dispatch. + * @param array $params Path parameters keyed by parameter name. + */ + public function setPathParameters(array $params): void + { + $this->setPathParametersDirect($params); + } + + /** + * @return array Path parameters extracted from the matched route pattern. + */ + public function getPathParameters(): array + { + return $this->getPathParametersDirect(); + } + + // ── Request helpers ──────────────────────────────────────────────────────── + + /** + * Returns a single path parameter by name. + * @param string $name Parameter name as declared in the route pattern. + * @param mixed $default Value to return when the parameter is absent. + * @return mixed Parameter value or $default. + */ + public function getPathParameter(string $name, mixed $default = null): mixed + { + return $this->getPathParameters()[$name] ?? $default; + } + + /** + * Returns the parsed request body. + * + * `Content-Type: application/json` requests decode the raw input stream; a + * non-empty body that is not valid JSON raises `400 Bad Request`. Form-encoded + * `POST` requests return `$_POST` directly; form-encoded `PUT` and `PATCH` + * requests parse `php://input` via `parse_str()` (PHP does not populate + * `$_POST` for non-`POST` verbs). Other verbs return an empty array. The + * result is cached for the lifetime of the resource. + * @throws TRestException 400 when a JSON body is present but malformed. + * @return array Parsed body data. + */ + public function getBody(): array + { + if ($this->getParsedBodyDirect() !== null) { + return $this->getParsedBodyDirect(); + } + + $request = $this->getRequest(); + $verb = strtoupper($request->getRequestType() ?? 'GET'); + + if (in_array($verb, ['POST', 'PUT', 'PATCH'], true)) { + $contentType = $request->getContentType() ?? ''; + if (str_contains($contentType, 'application/json')) { + $raw = $this->readRawRequestBody(); + if ($raw === '') { + $this->setParsedBodyDirect([]); + } else { + $decoded = json_decode($raw, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw TRestException::badRequest('Malformed JSON request body.'); + } + $this->setParsedBodyDirect(is_array($decoded) ? $decoded : []); + } + } elseif ($verb === 'POST') { + $this->setParsedBodyDirect($_POST); + } else { + // PHP only populates $_POST for POST requests; parse the raw stream + // for PUT/PATCH form-encoded bodies. + $parsed = []; + parse_str($this->readRawRequestBody(), $parsed); + $this->setParsedBodyDirect($parsed); + } + } else { + $this->setParsedBodyDirect([]); + } + + return $this->getParsedBodyDirect() ?? []; + } + + /** + * Reads the raw HTTP request body from `php://input`. + * + * Extracted as a protected seam so unit tests can override it without + * needing to register a stream wrapper. + * @return string Raw request body, or empty string when unavailable. + */ + protected function readRawRequestBody(): string + { + return (string) (file_get_contents('php://input') ?: ''); + } + + /** + * Returns the raw query-string parameters. + * + * Reads PHP's `$_GET` superglobal, which holds only true query-string + * values — {@see \Prado\Web\THttpRequest::itemAt()} would also surface + * routing parameters and form-`POST` fields. Extracted as a protected + * seam so unit tests can override it. + * @return array Query-string parameters keyed by name. + */ + protected function getQueryParams(): array + { + return $_GET; + } + + /** + * Returns a value from the request body first, then from the query string. + * @param string $key Field name. + * @param mixed $default Value to return when the key is absent. + * @return mixed + */ + public function input(string $key, mixed $default = null): mixed + { + $body = $this->getBody(); + if (array_key_exists($key, $body)) { + return $body[$key]; + } + return $this->getQueryParams()[$key] ?? $default; + } + + /** + * Returns a value from the query string only. + * Body fields, routing parameters, and form-`POST` values are excluded. + * @param string $key Query parameter name. + * @param mixed $default Value to return when the key is absent. + * @return mixed + */ + public function query(string $key, mixed $default = null): mixed + { + return $this->getQueryParams()[$key] ?? $default; + } + + /** + * Returns whether the key is present in the body or query string. + * @param string $key Field name. + * @return bool + */ + public function hasInput(string $key): bool + { + return array_key_exists($key, $this->getBody()) || array_key_exists($key, $this->getQueryParams()); + } + + /** + * Returns a subset of the request body containing only the given keys. + * @param array $keys Field names to include. + * @return array + */ + public function only(array $keys): array + { + return array_intersect_key($this->getBody(), array_flip($keys)); + } + + /** + * Returns the request body with the given keys removed. + * @param array $keys Field names to exclude. + * @return array + */ + public function except(array $keys): array + { + return array_diff_key($this->getBody(), array_flip($keys)); + } + + // ── Status helpers ───────────────────────────────────────────────────────── + + /** + * Sets the response status to 201 Created and returns the provided data. + * Call this in {@see doStore()} after successfully creating a resource. + * @param mixed $data Resource data to return in the response body. + * @return mixed The passed $data value. + */ + protected function created(mixed $data = null): mixed + { + $this->setStatusCode(201); + return $data; + } + + /** + * Sets the response status to 202 Accepted and returns the provided data. + * @param mixed $data Optional response body data. + * @return mixed The passed $data value. + */ + protected function accepted(mixed $data = null): mixed + { + $this->setStatusCode(202); + return $data; + } + + /** + * Sets the response status to 204 No Content. + * The response body will be suppressed by {@see TRestService}. + */ + protected function noContent(): void + { + $this->setStatusCode(204); + } + + /** + * Appends a custom response header. + * @param string $name Header name (e.g., `'X-Total-Count'`). + * @param string $value Header value. + * @return static + */ + protected function header(string $name, string $value): static + { + $this->addResponseHeader($name, $value); + return $this; + } + + // ── Exception helpers ────────────────────────────────────────────────────── + + /** + * Throws a {@see TRestException} with the given status code. + * @param int $status HTTP status code. + * @param string $detail Optional detail message. + * @param array $errors Optional field-level validation errors. + * @return never + */ + protected function abort(int $status, string $detail = '', array $errors = []): never + { + throw new TRestException($status, '', $detail, $errors); + } + + /** + * Throws a 404 Not Found exception. + * @param string $detail Optional detail message. + * @return never + */ + protected function notFound(string $detail = ''): never + { + throw TRestException::notFound($detail); + } + + /** + * Throws a 401 Unauthorized exception. + * @param string $detail Optional detail message. + * @return never + */ + protected function unauthorized(string $detail = ''): never + { + throw TRestException::unauthorized($detail); + } + + /** + * Throws a 403 Forbidden exception. + * @param string $detail Optional detail message. + * @return never + */ + protected function forbidden(string $detail = ''): never + { + throw TRestException::forbidden($detail); + } + + /** + * Throws a 409 Conflict exception. + * @param string $detail Optional detail message. + * @return never + */ + protected function conflict(string $detail = ''): never + { + throw TRestException::conflict($detail); + } + + /** + * Throws a 422 Unprocessable Entity exception with field-level errors. + * @param array $errors Validation errors keyed by field name. + * @param string $detail Optional detail message. + * @return never + */ + protected function unprocessable(array $errors, string $detail = ''): never + { + throw TRestException::unprocessable($errors, $detail); + } + + // ── Validation ───────────────────────────────────────────────────────────── + + /** + * Validates the given data array against a rule set. + * + * Returns the validated data (only the fields declared in $rules) with any + * applicable type coercions applied. Throws `422 Unprocessable Entity` if + * validation fails. + * + * Rule format: `'field' => 'rule1|rule2|rule3:param'`. + * + * Supported rules: + * - `required` — field must be present and non-null + * - `nullable` — field may be null; skips type rules when null + * - `string` — value must be a string + * - `integer` / `int` — value must be or cast to an integer + * - `float` / `numeric` — value must be numeric + * - `boolean` / `bool` — value must be boolean-ish (true/false/1/0/'1'/'0'/'true'/'false') + * - `array` — value must be an array + * - `email` — value must pass `FILTER_VALIDATE_EMAIL` + * - `url` — value must pass `FILTER_VALIDATE_URL` + * - `min:N` — string: minimum length N; number: minimum value N + * - `max:N` — string: maximum length N; number: maximum value N + * - `in:a,b,c` — value must be one of the comma-separated options + * + * Rule names outside this list raise a {@see TConfigurationException} so + * that typos (e.g. `'requried'`) surface during development instead of + * silently skipping validation. + * + * @param array $data Input data to validate (e.g., parsed request body). + * @param array $rules Rule set keyed by field name. + * @throws TRestException 422 when validation fails. + * @throws TConfigurationException when a rule name is not recognized. + * @return array Validated and type-coerced data containing only declared fields. + */ + protected function validate(array $data, array $rules): array + { + $errors = []; + $validated = []; + + foreach ($rules as $field => $ruleString) { + $fieldRules = is_array($ruleString) ? $ruleString : explode('|', $ruleString); + $required = in_array('required', $fieldRules, true); + $nullable = in_array('nullable', $fieldRules, true); + $present = array_key_exists($field, $data); + $value = $data[$field] ?? null; + + // Handle missing / null values + if (!$present || $value === null) { + if ($required && (!$present || $value === null)) { + $errors[$field][] = "The {$field} field is required."; + } elseif ($nullable && $present) { + $validated[$field] = null; + } + continue; + } + + $fieldValid = true; + foreach ($fieldRules as $rule) { + if ($rule === 'required' || $rule === 'nullable') { + continue; + } + + $ruleParts = explode(':', $rule, 2); + $ruleName = $ruleParts[0]; + $ruleParam = $ruleParts[1] ?? null; + + switch ($ruleName) { + case 'string': + if (!is_string($value)) { + $errors[$field][] = "The {$field} field must be a string."; + $fieldValid = false; + } + break; + + case 'integer': + case 'int': + // Use FILTER_VALIDATE_INT so strings like '--5', '1-2', '1.5', + // '5e2', and ' 5' are rejected rather than silently coerced. + if (is_int($value)) { + // already correct + } elseif (is_string($value) || is_float($value)) { + $filtered = filter_var($value, FILTER_VALIDATE_INT); + if ($filtered === false) { + $errors[$field][] = "The {$field} field must be an integer."; + $fieldValid = false; + } else { + $value = $filtered; + } + } else { + $errors[$field][] = "The {$field} field must be an integer."; + $fieldValid = false; + } + break; + + case 'float': + case 'numeric': + if (is_numeric($value)) { + $value = (float) $value; + } else { + $errors[$field][] = "The {$field} field must be numeric."; + $fieldValid = false; + } + break; + + case 'boolean': + case 'bool': + if (in_array($value, [true, false, 1, 0, '1', '0', 'true', 'false'], true)) { + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN); + } else { + $errors[$field][] = "The {$field} field must be a boolean."; + $fieldValid = false; + } + break; + + case 'array': + if (!is_array($value)) { + $errors[$field][] = "The {$field} field must be an array."; + $fieldValid = false; + } + break; + + case 'email': + if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { + $errors[$field][] = "The {$field} field must be a valid email address."; + $fieldValid = false; + } + break; + + case 'url': + if (!filter_var($value, FILTER_VALIDATE_URL)) { + $errors[$field][] = "The {$field} field must be a valid URL."; + $fieldValid = false; + } + break; + + case 'min': + $min = (float) $ruleParam; + if (is_string($value)) { + if (mb_strlen($value) < $min) { + $errors[$field][] = "The {$field} field must be at least {$ruleParam} characters."; + $fieldValid = false; + } + } elseif (is_numeric($value) && $value < $min) { + $errors[$field][] = "The {$field} field must be at least {$ruleParam}."; + $fieldValid = false; + } + break; + + case 'max': + $max = (float) $ruleParam; + if (is_string($value)) { + if (mb_strlen($value) > $max) { + $errors[$field][] = "The {$field} field must not exceed {$ruleParam} characters."; + $fieldValid = false; + } + } elseif (is_numeric($value) && $value > $max) { + $errors[$field][] = "The {$field} field must not exceed {$ruleParam}."; + $fieldValid = false; + } + break; + + case 'in': + $allowed = array_map('trim', explode(',', $ruleParam ?? '')); + if (!is_scalar($value) || !in_array((string) $value, $allowed, true)) { + $list = implode(', ', $allowed); + $errors[$field][] = "The {$field} field must be one of: {$list}."; + $fieldValid = false; + } + break; + + default: + throw new TConfigurationException('restresource_rule_unknown', $ruleName, $field); + } + } + + if ($fieldValid) { + $validated[$field] = $value; + } + } + + if ($errors !== []) { + throw TRestException::unprocessable($errors, 'The given data was invalid.'); + } + + return $validated; + } + + /** + * Validates the parsed request body against a rule set. + * + * Convenience wrapper around {@see validate()} that reads the body + * automatically. + * @param array $rules Rule set keyed by field name. + * @throws TRestException 422 when validation fails. + * @throws TConfigurationException when a rule name is not recognized. + * @return array Validated and type-coerced data. + */ + protected function validateBody(array $rules): array + { + return $this->validate($this->getBody(), $rules); + } +} diff --git a/framework/Web/Services/Rest/TRestService.php b/framework/Web/Services/Rest/TRestService.php new file mode 100644 index 000000000..84a3521c5 --- /dev/null +++ b/framework/Web/Services/Rest/TRestService.php @@ -0,0 +1,1342 @@ + + * @link https://github.com/pradosoft/prado + * @license https://github.com/pradosoft/prado/blob/master/LICENSE + */ + +namespace Prado\Web\Services\Rest; + +use Prado\Exceptions\TConfigurationException; +use Prado\Exceptions\TIOException; +use Prado\Prado; +use Prado\TApplicationMode; +use Prado\TPropertyValue; +use Prado\Xml\TXmlDocument; + +/** + * TRestService class + * + * TRestService provides a self-contained REST API layer for PRADO applications, + * intended as the backend for single-page applications built with React, Vue, + * Svelte, or any other client that consumes a JSON HTTP API. + * + * ## Request lifecycle + * + * On each request the service: + * + * 1. Emits CORS headers and short-circuits `OPTIONS` preflights when + * {@see setEnableCors EnableCors} is on. + * 2. Strips {@see setBasePath BasePath} from `PATH_INFO` and matches the + * remainder against its compiled route table in declaration order; the + * first matching pattern wins, otherwise the service returns `404`. + * Requests whose path lies outside `BasePath` also return `404`. + * 3. Instantiates the matched {@see TRestResource} subclass, applies any + * extra XML attributes as object properties, and injects the captured + * path parameters. + * 4. Calls {@see TRestResource::authorize() authorize()} (which may throw), + * then dispatches to a `do…()` method chosen from the HTTP verb and the + * pattern's shape (see *Routing & dispatch* below). + * 5. JSON-encodes the return value and writes it with the resource's chosen + * status code and headers. `HEAD` and `204` responses suppress the body. + * Any {@see TRestException} (or other `Throwable`) is converted to a JSON + * error envelope (see *Error responses* below). + * + * ## Routing & dispatch + * + * Patterns use `{name}` placeholders, optionally constrained per-parameter + * with `parameters.=""` (default: `[^/]+`). A route is classified + * as an **item** route when its final path segment is a placeholder, and as a + * **collection** route otherwise. The HTTP verb and the route shape together + * select the resource method: + * + * | Verb | Collection route | Item route | + * |------------|------------------|-----------------------------| + * | GET / HEAD | `doIndex()` | `doShow()` (HEAD omits body) | + * | POST | `doStore()` | `doStore()` | + * | PUT | `doStore()` | `doUpdate()` | + * | PATCH | `doStore()` | `doPatch()` | + * | DELETE | `doDestroy()` | `doDestroy()` | + * + * `PUT` and `PATCH` on a collection route both fall through to `doStore()` + * so that a resource may treat them as upsert; subclasses that do not want + * that behaviour simply omit `doStore()`, which yields `405`. Unknown verbs + * and resources that do not implement the selected method also yield `405 + * Method Not Allowed`. Path parameters are passed to the resource method + * **by name** via PHP reflection — parameter order in the signature does + * not matter, and unmatched parameters fall back to PHP defaults. + * + * ## Configuring resources + * + * Routes are declared as `` elements directly under the ``, + * or inside `` elements that share a common URL prefix. Groups support + * an `enabled` attribute that accepts boolean-ish values plus the + * case-insensitive string `'Debug'`, which evaluates to `true` only when + * `TApplicationMode::Debug` is active — useful for diagnostic endpoints that + * should disappear in `Normal` and `Performance` modes. URL routing is set up + * once on the request module, then resources are declared on the service: + * + * ```xml + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * ``` + * + * The same configuration expressed as a PHP array (when the application is + * configured with `CONFIG_TYPE_PHP`) — `` and `` attributes + * become array keys, and `parameters.="…"` collapses to a nested + * `'parameters'` array: + * + * ```php + * 'services' => [ + * 'rest' => [ + * 'class' => 'Prado\\Web\\Services\\Rest\\TRestService', + * 'properties' => [ + * 'BasePath' => 'api/', + * 'EnableCors' => 'true', + * 'AllowOrigin' => 'https://myapp.example.com', + * ], + * 'resources' => [ + * ['pattern' => 'users', 'class' => 'App.Api.UsersResource'], + * ['pattern' => 'users/{id}', 'class' => 'App.Api.UsersResource', + * 'parameters' => ['id' => '\d+']], + * ['pattern' => 'users/{userId}/posts', 'class' => 'App.Api.UserPostsResource', + * 'parameters' => ['userId' => '\d+']], + * ['pattern' => 'users/{userId}/posts/{id}', 'class' => 'App.Api.UserPostsResource', + * 'parameters' => ['userId' => '\d+', 'id' => '\d+']], + * ], + * 'groups' => [ + * ['prefix' => 'v1/', 'enabled' => true, 'resources' => [ + * ['pattern' => 'users', 'class' => 'App.Api.V1.UsersResource'], + * ]], + * ['prefix' => 'v2/', 'groupfile' => 'Application.config.rest-v2'], + * ['prefix' => 'v3/', 'enabled' => false, 'resources' => [ + * ['pattern' => 'users', 'class' => 'App.Api.V3.UsersResource'], + * ]], + * ['prefix' => 'debug/', 'enabled' => 'Debug', 'resources' => [ + * ['pattern' => 'dump', 'class' => 'App.Api.Debug.DumpResource'], + * ]], + * ], + * ], + * ], + * ``` + * + * ## External configuration files + * + * Either the full service config or a single group's resources can live in a + * separate file referenced by Prado namespace path (without extension). The + * loader prefers `.php` and falls back to `.xml`; for XML files the root + * element name is arbitrary, only direct `` (and, for `configfile`, + * ``) children are read. + * + * - **``** loads top-level `resources` and `groups`. + * Inline entries on the `` element are appended afterwards, so + * file contents act as a base that inline entries can extend. + * - **``** loads only the `resources` for that group. + * The `prefix` and `enabled` flag stay on the referencing ``. + * + * A representative pair of external files — one service-level, one + * group-level — looks like: + * + * ```xml + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * ``` + * + * The PHP-array equivalents return `['resources' => […], 'groups' => […]]` + * for a service-level file and `['resources' => […]]` for a group-level + * file, with each entry using the same keys as the XML attributes. + * + * ## CORS + * + * When {@see setEnableCors EnableCors} is on, `Access-Control-Allow-Origin` + * (plus `Access-Control-Allow-Credentials` and `Vary: Origin` when + * applicable) is added to every response, and `OPTIONS` preflights are + * answered with `204 No Content` carrying the preflight-only headers + * `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers`, and + * `Access-Control-Max-Age`. {@see setAllowOrigin AllowOrigin}, + * {@see setAllowMethods AllowMethods}, {@see setAllowHeaders AllowHeaders}, + * {@see setAllowCredentials AllowCredentials}, and {@see setMaxAge MaxAge} + * control the emitted values. Combining {@see setAllowCredentials + * AllowCredentials} with the wildcard `'*'` origin is rejected as a + * configuration error — reflecting arbitrary origins while allowing + * credentials would grant every website authenticated access to the API, so + * an explicit origin must be configured instead. + * + * ## Error responses + * + * Every error — routing failures, method mismatches, thrown + * {@see TRestException} instances, and uncaught exceptions — is serialized as + * an RFC 7807-inspired JSON envelope. Validation failures additionally + * include an `errors` key: + * + * ```json + * { "status": 404, "title": "Not Found", "detail": "User 42 not found." } + * { "status": 422, "title": "Unprocessable Entity", + * "detail": "The given data was invalid.", + * "errors": { "email": ["The email field is required."] } } + * ``` + * + * Uncaught exceptions become `500` responses; their `getMessage()` is + * included in `detail` only when {@see setExposeErrors ExposeErrors} is on + * (the default in `TApplicationMode::Debug`). + * + * @author Brad Anderson + * @since 4.4.0 + */ +class TRestService extends \Prado\TService +{ + /** + * @var string Base URL path prefix stripped before route matching. + * For example, `"api/"` means `PATH_INFO` of `/api/users/1` is matched + * as `users/1`. Include the trailing slash. Defaults to empty string. + */ + private string $_basePath = ''; + + /** + * @var bool Whether to emit CORS headers. Defaults to false. + */ + private bool $_enableCors = false; + + /** + * @var string Value of the `Access-Control-Allow-Origin` header. + * Use `'*'` to allow any origin. Defaults to `'*'`. + */ + private string $_allowOrigin = '*'; + + /** + * @var string Comma-separated list of allowed HTTP methods. + */ + private string $_allowMethods = 'GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD'; + + /** + * @var string Comma-separated list of allowed request headers. + */ + private string $_allowHeaders = 'Content-Type, Authorization, X-Requested-With, Accept'; + + /** + * @var bool Whether `Access-Control-Allow-Credentials` is sent. Defaults to false. + */ + private bool $_allowCredentials = false; + + /** + * @var int Max-age in seconds for preflight cache. Defaults to 86400 (24 h). + */ + private int $_maxAge = 86400; + + /** + * @var bool Whether to expose internal exception messages in 500 responses. + * Automatically true when the application runs in Debug mode. + */ + private bool $_exposeErrors = false; + + /** + * @var array Compiled route table. Each entry: + * [ 'pattern' => string, 'regex' => string, 'class' => string, + * 'isItem' => bool, 'paramOrder' => string[], 'properties' => array ] + */ + private array $_resources = []; + + // ── Initialisation ───────────────────────────────────────────────────────── + + /** + * Initializes the service by validating the CORS configuration and + * compiling the resource route table. + * @param mixed $config Service configuration element. + * @throws TConfigurationException when CORS credentials are combined with + * the wildcard origin, or a resource declaration is invalid (missing + * `pattern`/`class`, bad route parameter name, or non-resource class). + * @throws TIOException when a referenced config or group file cannot be found. + */ + public function init($config): void + { + $this->setExposeErrors($this->getApplication()->getMode() === TApplicationMode::Debug); + $this->assertValidCorsConfig(); + $this->loadResources($config); + parent::init($config); + } + + /** + * Guards against the insecure combination of credentialed CORS and a + * wildcard origin. + * + * Reflecting arbitrary request origins while sending + * `Access-Control-Allow-Credentials: true` would let any website make + * authenticated requests with the user's cookies, which the CORS + * specification deliberately forbids. Called from {@see init()} for + * fail-fast configuration errors and from {@see sendCorsHeaders()} to + * cover properties changed programmatically after initialization. + * + * @throws TConfigurationException when EnableCors and AllowCredentials are + * both true while AllowOrigin is `'*'`. + */ + protected function assertValidCorsConfig(): void + { + if ($this->getEnableCors() && $this->getAllowCredentials() && $this->getAllowOrigin() === '*') { + throw new TConfigurationException('restservice_cors_credentials_wildcard'); + } + } + + /** + * Parses `` and `` elements from the service configuration + * and compiles each resource pattern into a named-capture regular expression. + * + * XML configurations are first normalized to a PHP array via + * {@see xmlConfigToArray()}; only the PHP array path is then parsed. This + * ensures a single, consistent parsing code path regardless of config format. + * + * Top-level `` elements are registered directly. `` elements + * are delegated to {@see loadGroup()}, which prepends the group prefix to every + * pattern before the routes are added to the table. Disabled groups + * (`enabled="false"`) are skipped entirely. + * + * A `configfile` attribute on the service (or `'configfile'` key in PHP + * config) loads an external file containing the same `resources` + `groups` + * structure; its contents are processed first, then inline entries are + * appended. The external file may itself reference further `groupfile`s. + * + * @param mixed $config Service configuration element. + * @throws TConfigurationException when required attributes are missing. + * @throws TIOException when a referenced config or group file cannot be found. + */ + protected function loadResources(mixed $config): void + { + if ($config === null) { + return; + } + + if ($config instanceof \Prado\Xml\TXmlElement) { + $phpConfig = $this->xmlConfigToArray($config); + } elseif (is_array($config)) { + $phpConfig = $config; + } else { + return; + } + + // Merge an external configfile (if any) ahead of inline entries. + $configFile = (string) ($phpConfig['configfile'] ?? ''); + if ($configFile !== '') { + $external = $this->loadConfigFile($configFile); + $phpConfig['resources'] = array_merge($external['resources'] ?? [], $phpConfig['resources'] ?? []); + $phpConfig['groups'] = array_merge($external['groups'] ?? [], $phpConfig['groups'] ?? []); + } + + foreach ($phpConfig['resources'] ?? [] as $item) { + $this->registerResource($item); + } + foreach ($phpConfig['groups'] ?? [] as $groupConfig) { + $this->loadGroup($groupConfig); + } + } + + /** + * Converts a service XML config element to a PHP array suitable for + * {@see loadResources()}. + * + * Only direct child elements are inspected: `` elements are + * collected under `'resources'`; `` elements are collected under + * `'groups'`. This prevents double-registration of resources that live + * inside `` elements. The `configfile` attribute, if present on + * the service element, is preserved under the `'configfile'` key. + * + * @param \Prado\Xml\TXmlElement $config Service XML element. + * @return array{resources: array, groups: array, configfile?: string} + */ + protected function xmlConfigToArray(\Prado\Xml\TXmlElement $config): array + { + $result = ['resources' => [], 'groups' => []]; + + $configFile = $config->getAttribute('configfile'); + if ($configFile !== null && $configFile !== '') { + $result['configfile'] = $configFile; + } + + foreach ($config->getElements() as $element) { + $tagName = strtolower($element->getTagName()); + if ($tagName === 'resource') { + $result['resources'][] = $this->xmlResourceToArray($element); + } elseif ($tagName === 'group') { + $result['groups'][] = $this->xmlGroupToArray($element); + } + } + + return $result; + } + + /** + * Converts a `` XML element to a PHP array. + * + * All attributes are preserved as top-level keys. Direct `` child + * elements are collected under the `'resources'` key via + * {@see xmlResourceToArray()}. + * + * @param \Prado\Xml\TXmlElement $element XML group element. + * @return array Group config array with at least a `'resources'` key. + */ + protected function xmlGroupToArray(\Prado\Xml\TXmlElement $element): array + { + $group = []; + + foreach ($element->getAttributes()->toArray() as $key => $value) { + $group[$key] = $value; + } + + $resources = []; + foreach ($element->getElements() as $child) { + if (strtolower($child->getTagName()) === 'resource') { + $resources[] = $this->xmlResourceToArray($child); + } + } + $group['resources'] = $resources; + + return $group; + } + + /** + * Converts a `` XML element to a PHP array. + * + * Attributes prefixed with `'parameters.'` are collected into a nested + * `'parameters'` array. All remaining attributes are preserved as top-level + * keys (including `'pattern'` and `'class'`). + * + * @param \Prado\Xml\TXmlElement $element XML resource element. + * @return array Resource config array. + */ + protected function xmlResourceToArray(\Prado\Xml\TXmlElement $element): array + { + $item = []; + $parameters = []; + + foreach ($element->getAttributes()->toArray() as $key => $value) { + if (str_starts_with($key, 'parameters.')) { + $parameters[substr($key, 11)] = $value; + } else { + $item[$key] = $value; + } + } + + if ($parameters !== []) { + $item['parameters'] = $parameters; + } + + return $item; + } + + /** + * Registers a resource from a PHP array config entry. + * + * Validates that `'pattern'` and `'class'` keys are present, extracts + * `'parameters'` constraints, and passes all remaining keys as resource + * properties to {@see addResource()}. + * + * @param array $item Resource config array. Must contain `'pattern'` and `'class'`. + * @param string $prefix Optional URL prefix to prepend to the pattern. + * @throws TConfigurationException when a required key is absent. + */ + protected function registerResource(array $item, string $prefix = ''): void + { + if (!isset($item['pattern'])) { + throw new TConfigurationException('restservice_pattern_required'); + } + if (!isset($item['class'])) { + throw new TConfigurationException('restservice_class_required'); + } + + // An individual resource may carry an `enabled` flag (boolean-ish, or the + // case-insensitive `'Debug'`), mirroring ``; a disabled resource is skipped. + if (array_key_exists('enabled', $item) && !$this->isEnabled($item['enabled'])) { + return; + } + + $pattern = $prefix . $item['pattern']; + $class = $item['class']; + $parameters = $item['parameters'] ?? []; + $properties = array_diff_key($item, array_flip(['pattern', 'class', 'parameters', 'enabled'])); + + $this->addResource($pattern, $class, $parameters, $properties); + } + + /** + * Processes a group config array and registers all enabled resources. + * + * Reads `'prefix'`, `'enabled'`, and `'groupfile'` from the config. + * All other keys are available to subclass overrides. File-based resources + * are loaded first via {@see loadGroupFile()}; inline `'resources'` entries + * are appended afterward. Disabled groups are skipped entirely. + * + * The `'enabled'` value is resolved by {@see isEnabled()}, which accepts + * standard boolean-ish strings plus the special value `'Debug'` (enabled + * only when the application runs in Debug mode). + * + * @param array $config Group config array. + * @throws TIOException when a referenced group file cannot be found. + */ + protected function loadGroup(array $config): void + { + $prefix = (string) ($config['prefix'] ?? ''); + $enabled = $this->isEnabled($config['enabled'] ?? true); + $groupFile = (string) ($config['groupfile'] ?? ''); + + if (!$enabled) { + return; + } + + $resources = []; + + if ($groupFile !== '') { + $resources = $this->loadGroupFile($groupFile); + } + + foreach ($config['resources'] ?? [] as $item) { + $resources[] = $item; + } + + foreach ($resources as $item) { + $this->registerResource($item, $prefix); + } + } + + /** + * Resolves whether a group is enabled based on a config value. + * + * Accepts any value `TPropertyValue::ensureBoolean()` understands, plus the + * case-insensitive string `'Debug'`, which resolves to `true` only when the + * application's mode is {@see TApplicationMode::Debug} (and `false` + * for `Normal` or `Performance` mode). + * + * @param mixed $value Raw `enabled` value from the config. + * @return bool Whether the group is enabled in the current application mode. + */ + protected function isEnabled(mixed $value): bool + { + if (is_string($value) && strcasecmp($value, 'Debug') === 0) { + return $this->getApplication()->getMode() === TApplicationMode::Debug; + } + return TPropertyValue::ensureBoolean($value); + } + + /** + * Resolves and loads an external service-level config file. + * + * The file contains the same structure as the inline service config — + * top-level `resources` and/or `groups` entries. Tries `.php` first; falls + * back to `.xml`. The PHP file must return an array with `'resources'` + * and/or `'groups'` keys. The XML file is treated like an inline `` + * element: direct `` and `` children are loaded; the root + * element name is arbitrary. + * + * Groups loaded from the external file may themselves reference + * `groupfile` entries — those are resolved by {@see loadGroupFile()} when + * {@see loadGroup()} processes each group. + * + * @param string $file Prado namespace path without extension (e.g. + * `'Application.config.rest'`). + * @throws TIOException when neither a `.php` nor a `.xml` file is found. + * @return array{resources: array, groups: array} Service config array. + */ + protected function loadConfigFile(string $file): array + { + $phpFile = Prado::getPathOfNamespace($file, '.php'); + if ($phpFile !== null && is_file($phpFile)) { + $config = include $phpFile; + if (!is_array($config)) { + return ['resources' => [], 'groups' => []]; + } + return [ + 'resources' => $config['resources'] ?? [], + 'groups' => $config['groups'] ?? [], + ]; + } + + $xmlFile = Prado::getPathOfNamespace($file, '.xml'); + if ($xmlFile !== null && is_file($xmlFile)) { + $dom = new TXmlDocument('1.0', 'UTF-8'); + $dom->loadFromFile($xmlFile); + return $this->xmlConfigToArray($dom); + } + + throw new TIOException('restconfig_file_not_found', $file); + } + + /** + * Resolves and loads an external resource definition file. + * + * Tries a `.php` file first; falls back to `.xml`. The PHP file must return + * an array with a `'resources'` key. The XML file must have `` + * elements as direct children of its root element; the root element name + * itself is arbitrary and ignored. + * + * @param string $file Prado namespace path without extension (e.g. + * `'Application.config.rest-v2'`). + * @throws TIOException when neither a `.php` nor a `.xml` file is found. + * @return array Flat array of resource config arrays. + */ + protected function loadGroupFile(string $file): array + { + $phpFile = Prado::getPathOfNamespace($file, '.php'); + if ($phpFile !== null && is_file($phpFile)) { + $config = include $phpFile; + return is_array($config) ? ($config['resources'] ?? []) : []; + } + + $xmlFile = Prado::getPathOfNamespace($file, '.xml'); + if ($xmlFile !== null && is_file($xmlFile)) { + $dom = new TXmlDocument('1.0', 'UTF-8'); + $dom->loadFromFile($xmlFile); + $resources = []; + foreach ($dom->getElementsByTagName('resource') as $element) { + $resources[] = $this->xmlResourceToArray($element); + } + return $resources; + } + + throw new TIOException('restgroup_file_not_found', $file); + } + + /** + * Adds a compiled resource entry to the route table. + * @param string $pattern URL pattern (e.g., `'users/{id}'`). + * @param string $class Resource class in PRADO namespace format. + * @param array $parameters Per-parameter regex constraints (key = param name, value = regex fragment). + * @param array $properties Additional properties to set on the resource after instantiation. + */ + protected function addResource(string $pattern, string $class, array $parameters = [], array $properties = []): void + { + [$regex, $paramOrder, $isItem] = $this->compilePattern($pattern, $parameters); + + $this->addResourceEntryDirect([ + 'pattern' => $pattern, + 'regex' => $regex, + 'class' => $class, + 'isItem' => $isItem, + 'paramOrder' => $paramOrder, + 'properties' => $properties, + ]); + } + + /** + * Compiles a URL pattern string into a named-capture regular expression. + * + * `{paramName}` placeholders become `(?P(?:constraint))`; the + * inner non-capturing group lets user constraints contain top-level + * alternation (`\d+|new`) without leaking out of the named capture. + * Literal path segments are regex-escaped and the result is anchored. + * + * @param string $pattern URL pattern, e.g., `'users/{userId}/posts/{id}'`. + * @param array $parameters Regex constraints keyed by parameter name. Defaults to `[^/]+`. + * @return array{0: string, 1: string[], 2: bool} [regex, paramOrder, isItem] + */ + protected function compilePattern(string $pattern, array $parameters): array + { + // Extract parameter names in order + preg_match_all('/\{([^}]+)\}/', $pattern, $matches); + $paramOrder = $matches[1]; + + // Fail fast on names that are not valid PCRE named-group identifiers, or on + // duplicates (PCRE rejects repeated group names, which would silently break routing). + foreach ($paramOrder as $paramName) { + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $paramName)) { + throw new TConfigurationException('restservice_param_name_invalid', $paramName, $pattern); + } + } + if (count($paramOrder) !== count(array_unique($paramOrder))) { + throw new TConfigurationException('restservice_param_name_duplicate', $pattern); + } + + // Split pattern on {param} tokens, escape literal parts, rejoin with named captures + $literalParts = preg_split('/\{[^}]+\}/', $pattern); + $regexParts = []; + + foreach ($paramOrder as $i => $paramName) { + $constraint = $parameters[$paramName] ?? '[^/]+'; + // Wrap constraint in (?:...) so user-supplied alternations like "\d+|\d{4}-\d{2}" + // don't break the named capture group. + $regexParts[] = preg_quote($literalParts[$i], '#') . '(?P<' . $paramName . '>(?:' . $constraint . '))'; + } + $regexParts[] = preg_quote($literalParts[count($paramOrder)], '#'); + + $regex = '#^' . implode('', $regexParts) . '$#u'; + + // A route is an "item" route when the last path segment is a {param} + $isItem = (bool) preg_match('/\{[^}]+\}\s*$/', rtrim($pattern, '/')); + + return [$regex, $paramOrder, $isItem]; + } + + // ── Request Handling ─────────────────────────────────────────────────────── + + /** + * Runs the service. Handles CORS, route matching, dispatch, and response encoding. + */ + public function run(): void + { + $request = $this->getRequest(); + $response = $this->getResponse(); + + try { + // CORS — must be first so preflight OPTIONS responses work + if ($this->getEnableCors()) { + $this->sendCorsHeaders(); + if (strtoupper($request->getRequestType() ?? '') === 'OPTIONS') { + $response->setStatusCode(204); + return; + } + } + + $path = $this->getApiPath(); + [$resourceConfig, $pathParams] = $this->matchRoute($path); + + // Instantiate the resource + $resource = $this->createResource($resourceConfig); + $resource->setPathParameters($pathParams); + + // Resolve HTTP verb → dispatch method name + $verb = strtoupper($request->getRequestType() ?? 'GET'); + $method = $this->resolveMethod($verb, $resourceConfig['isItem']); + + // Auth hook — may throw TRestException + $resource->authorize($method); + + // Dispatch — inject path params by name via reflection + $result = $this->dispatchToResource($resource, $method, $pathParams); + + // Send response + $statusCode = $resource->getStatusCode(); + $headers = $resource->getResponseHeaders(); + + // HEAD: same logic as GET but no body + // 204 No Content: no body regardless of verb + if ($verb === 'HEAD' || $statusCode === 204) { + $response->setStatusCode($statusCode); + foreach ($headers as $name => $value) { + $response->appendHeader("{$name}: {$value}"); + } + return; + } + + $this->sendJsonResponse($result, $statusCode, $headers); + } catch (TRestException $e) { + if ($e->getStatusCode() === 405 && isset($resource, $resourceConfig)) { + // RFC 7231 §6.5.5: a 405 response must carry an Allow header. + $response->appendHeader('Allow: ' . implode(', ', $this->getAllowedVerbs($resource, $resourceConfig['isItem']))); + } + $this->sendErrorResponse($e); + } catch (\Prado\Exceptions\THttpException $e) { + $this->sendErrorResponse(new TRestException($e->getStatusCode(), '', $e->getMessage())); + } catch (\Throwable $e) { + $detail = $this->getExposeErrors() ? $e->getMessage() : ''; + Prado::log($e->getMessage(), \Prado\Util\TLogger::ERROR, self::class); + $this->sendErrorResponse(new TRestException(500, '', $detail)); + } + } + + // ── Routing ──────────────────────────────────────────────────────────────── + + /** + * Returns the portion of PATH_INFO that follows the configured BasePath. + * + * For example, if BasePath is `"api/"` and PATH_INFO is `/api/users/42`, + * this method returns `"users/42"`. + * @throws TRestException 404 when the path lies outside BasePath. + * @return string Relative API path, with no leading slash. + */ + protected function getApiPath(): string + { + $pathInfo = ltrim($this->getRequest()->getPathInfo() ?? '', '/'); + return $this->applyBasePath($pathInfo); + } + + /** + * Strips the configured BasePath prefix from a raw path-info string. + * + * Leading slashes are removed before comparison. When BasePath is empty, + * the path is returned unchanged. A path equal to BasePath without its + * trailing slash resolves to the empty root path. A path outside BasePath + * is rejected with `404` so routes are reachable only under the + * configured prefix. + * + * @param string $pathInfo Raw path info (leading slash already stripped). + * @throws TRestException 404 when the path lies outside BasePath. + * @return string API-relative path, with no leading slash. + */ + protected function applyBasePath(string $pathInfo): string + { + $basePath = trim($this->getBasePath(), '/'); + + if ($basePath === '') { + return $pathInfo; + } + if ($pathInfo === $basePath) { + return ''; + } + // Match only on a segment boundary so BasePath "api" does not capture "apidocs/…". + if (str_starts_with($pathInfo, $basePath . '/')) { + return ltrim(substr($pathInfo, strlen($basePath) + 1), '/'); + } + + throw TRestException::notFound('The requested resource was not found.'); + } + + /** + * Matches the given path against the compiled route table. + * + * Routes are tested in declaration order; the first match wins. + * + * @param string $path API path (relative, no leading slash). + * @throws TRestException 404 when no route matches. + * @return array{0: array, 1: array} [resourceConfig, pathParams] + */ + protected function matchRoute(string $path): array + { + foreach ($this->getResourcesDirect() as $resourceConfig) { + if (preg_match($resourceConfig['regex'], $path, $matches)) { + $pathParams = []; + foreach ($resourceConfig['paramOrder'] as $paramName) { + if (isset($matches[$paramName])) { + $pathParams[$paramName] = $matches[$paramName]; + } + } + return [$resourceConfig, $pathParams]; + } + } + + throw TRestException::notFound('The requested resource was not found.'); + } + + // ── Dispatch ─────────────────────────────────────────────────────────────── + + /** + * Instantiates a TRestResource from a compiled route config entry. + * + * Applies any extra properties declared in the resource element. Path + * parameters are injected separately by {@see run()} after instantiation. + * + * @param array $resourceConfig Compiled route config entry. + * @throws TConfigurationException when the class does not extend TRestResource. + * @return TRestResource + */ + protected function createResource(array $resourceConfig): TRestResource + { + $class = $resourceConfig['class']; + + // Prado::createComponent handles both dot-notation and PHP namespaces + $resource = Prado::createComponent($class); + + if (!($resource instanceof TRestResource)) { + throw new TConfigurationException('restservice_resource_invalid', $class); + } + + foreach ($resourceConfig['properties'] as $name => $value) { + $resource->setSubproperty($name, $value); + } + + return $resource; + } + + /** + * Maps an HTTP verb and route type to the TRestResource dispatch method name. + * + * | Verb | Collection | Item | + * |------|-----------|------| + * | GET / HEAD | `doIndex` | `doShow` | + * | POST | `doStore` | `doStore` | + * | PUT | `doStore` | `doUpdate` | + * | PATCH | `doStore` | `doPatch` | + * | DELETE | `doDestroy` | `doDestroy` | + * + * @param string $verb Uppercase HTTP method (e.g., `'GET'`, `'POST'`). + * @param bool $isItem True for item routes (last segment is a `{param}`). + * @throws TRestException 405 for unsupported verbs. + * @return string Method name to call on the resource. + */ + protected function resolveMethod(string $verb, bool $isItem): string + { + return match ($verb) { + 'GET', 'HEAD' => $isItem ? 'doShow' : 'doIndex', + 'POST' => 'doStore', + 'PUT' => $isItem ? 'doUpdate' : 'doStore', + 'PATCH' => $isItem ? 'doPatch' : 'doStore', + 'DELETE' => 'doDestroy', + default => throw TRestException::methodNotAllowed("HTTP method {$verb} is not supported."), + }; + } + + /** + * Returns the HTTP verbs the given resource supports for the matched route. + * + * A verb is supported when the resource subclass declares the convention + * method that {@see resolveMethod()} maps it to. `OPTIONS` is appended + * when CORS is enabled because the service answers preflights itself. + * Used to populate the `Allow` header on `405` responses. + * + * @param TRestResource $resource Resource instance for the matched route. + * @param bool $isItem True for item routes (last segment is a `{param}`). + * @return string[] Supported verbs in canonical order. + */ + protected function getAllowedVerbs(TRestResource $resource, bool $isItem): array + { + $verbs = []; + foreach (['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as $verb) { + if (method_exists($resource, $this->resolveMethod($verb, $isItem))) { + $verbs[] = $verb; + } + } + if ($this->getEnableCors()) { + $verbs[] = 'OPTIONS'; + } + return $verbs; + } + + /** + * Calls a method on a TRestResource, injecting path parameters by name. + * + * Uses PHP reflection to read the method's parameter list and passes only + * those path params whose names match declared parameters, in declaration order. + * Parameters with default values are used when a matching path param is absent. + * + * @param TRestResource $resource Resource instance. + * @param string $method Method name to call. + * @param array $pathParams Path parameters extracted from the URL, keyed by name. + * @throws TRestException 405 when the resource does not implement the method. + * @throws TRestException 500 when a required parameter cannot be satisfied. + * @return mixed Return value of the resource method. + */ + protected function dispatchToResource(TRestResource $resource, string $method, array $pathParams): mixed + { + if (!method_exists($resource, $method)) { + throw TRestException::methodNotAllowed(); + } + + $ref = new \ReflectionMethod($resource, $method); + $args = []; + + foreach ($ref->getParameters() as $param) { + $name = $param->getName(); + if (array_key_exists($name, $pathParams)) { + $args[] = $pathParams[$name]; + } elseif ($param->isDefaultValueAvailable()) { + $args[] = $param->getDefaultValue(); + } else { + throw new TRestException( + 500, + 'Internal Server Error', + "Resource method '{$method}' requires path parameter '\${$name}' which is not defined in the matched route." + ); + } + } + + return $resource->$method(...$args); + } + + // ── Response ─────────────────────────────────────────────────────────────── + + /** + * Encodes $data as JSON and writes it to the HTTP response with the given status code. + * + * A `null` $data value produces a response with no body (useful for `204`). + * + * @param mixed $data Response payload. Must be JSON-serializable. + * @param int $statusCode HTTP status code. Defaults to 200. + * @param array $headers Additional response headers as name => value pairs. + * @throws \JsonException when the payload cannot be JSON-encoded. + */ + protected function sendJsonResponse(mixed $data, int $statusCode = 200, array $headers = []): void + { + $response = $this->getResponse(); + $response->setStatusCode($statusCode); + + foreach ($headers as $name => $value) { + $response->appendHeader("{$name}: {$value}"); + } + + if ($data !== null) { + $response->setContentType('application/json'); + $response->setCharset('UTF-8'); + $response->write(json_encode($data, JSON_THROW_ON_ERROR)); + } + } + + /** + * Serializes a TRestException to JSON and writes it to the HTTP response. + * @param TRestException $e Exception to serialize. + * @throws \JsonException when the error envelope cannot be JSON-encoded. + */ + protected function sendErrorResponse(TRestException $e): void + { + $response = $this->getResponse(); + $response->setStatusCode($e->getStatusCode()); + $response->setContentType('application/json'); + $response->setCharset('UTF-8'); + $response->write(json_encode($e->toArray(), JSON_THROW_ON_ERROR)); + } + + // ── CORS ─────────────────────────────────────────────────────────────────── + + /** + * Emits `Access-Control-*` headers for CORS support. + * + * Called at the top of {@see run()} when {@see getEnableCors()} is true. + * The origin headers (`Access-Control-Allow-Origin`, + * `Access-Control-Allow-Credentials`, `Vary: Origin`) are sent on every + * response; the preflight-only headers (`Access-Control-Allow-Methods`, + * `Access-Control-Allow-Headers`, `Access-Control-Max-Age`) are sent only + * for `OPTIONS` requests because browsers ignore them elsewhere. + * + * @throws TConfigurationException when CORS credentials are combined with + * the wildcard origin (see {@see assertValidCorsConfig()}). + */ + protected function sendCorsHeaders(): void + { + $this->assertValidCorsConfig(); + + $response = $this->getResponse(); + $origin = $this->getAllowOrigin(); + + $response->appendHeader("Access-Control-Allow-Origin: {$origin}"); + + if ($this->getAllowCredentials()) { + $response->appendHeader('Access-Control-Allow-Credentials: true'); + } + + if ($origin !== '*') { + $response->appendHeader('Vary: Origin'); + } + + if (strtoupper($this->getRequest()->getRequestType() ?? '') === 'OPTIONS') { + $response->appendHeader("Access-Control-Allow-Methods: {$this->getAllowMethods()}"); + $response->appendHeader("Access-Control-Allow-Headers: {$this->getAllowHeaders()}"); + $response->appendHeader("Access-Control-Max-Age: {$this->getMaxAge()}"); + } + } + + // ── Direct Accessors (UAP-SE) ────────────────────────────────────────────── + + /** + * @return string Base URL path prefix stored value. + */ + protected function getBasePathDirect(): string + { + return $this->_basePath; + } + + /** + * @param string $value Base URL path prefix. + */ + protected function setBasePathDirect(string $value): void + { + $this->_basePath = $value; + } + + /** + * @return bool Stored EnableCors flag. + */ + protected function getEnableCorsDirect(): bool + { + return $this->_enableCors; + } + + /** + * @param bool $value EnableCors stored value. + */ + protected function setEnableCorsDirect(bool $value): void + { + $this->_enableCors = $value; + } + + /** + * @return string Stored AllowOrigin value. + */ + protected function getAllowOriginDirect(): string + { + return $this->_allowOrigin; + } + + /** + * @param string $value AllowOrigin stored value. + */ + protected function setAllowOriginDirect(string $value): void + { + $this->_allowOrigin = $value; + } + + /** + * @return string Stored AllowMethods value. + */ + protected function getAllowMethodsDirect(): string + { + return $this->_allowMethods; + } + + /** + * @param string $value AllowMethods stored value. + */ + protected function setAllowMethodsDirect(string $value): void + { + $this->_allowMethods = $value; + } + + /** + * @return string Stored AllowHeaders value. + */ + protected function getAllowHeadersDirect(): string + { + return $this->_allowHeaders; + } + + /** + * @param string $value AllowHeaders stored value. + */ + protected function setAllowHeadersDirect(string $value): void + { + $this->_allowHeaders = $value; + } + + /** + * @return bool Stored AllowCredentials flag. + */ + protected function getAllowCredentialsDirect(): bool + { + return $this->_allowCredentials; + } + + /** + * @param bool $value AllowCredentials stored value. + */ + protected function setAllowCredentialsDirect(bool $value): void + { + $this->_allowCredentials = $value; + } + + /** + * @return int Stored MaxAge value in seconds. + */ + protected function getMaxAgeDirect(): int + { + return $this->_maxAge; + } + + /** + * @param int $value MaxAge stored value in seconds. + */ + protected function setMaxAgeDirect(int $value): void + { + $this->_maxAge = $value; + } + + /** + * @return bool Stored ExposeErrors flag. + */ + protected function getExposeErrorsDirect(): bool + { + return $this->_exposeErrors; + } + + /** + * @param bool $value ExposeErrors stored value. + */ + protected function setExposeErrorsDirect(bool $value): void + { + $this->_exposeErrors = $value; + } + + /** + * Returns the compiled route table directly, bypassing any subclass override + * of public accessor methods. + * @return array Compiled route table. + */ + protected function getResourcesDirect(): array + { + return $this->_resources; + } + + /** + * Appends a compiled route entry to the route table directly. + * @param array $entry Compiled route entry. + */ + protected function addResourceEntryDirect(array $entry): void + { + $this->_resources[] = $entry; + } + + // ── Property Accessors ───────────────────────────────────────────────────── + + /** + * @return string Base URL path prefix. Defaults to empty string. + */ + public function getBasePath(): string + { + return $this->getBasePathDirect(); + } + + /** + * Sets the base URL path prefix that is stripped before route matching. + * + * For example, setting `BasePath="api/v1/"` means a request for `/api/v1/users` + * is matched against the pattern `users`. + * @param string $value Base path, with optional leading/trailing slashes. + */ + public function setBasePath(string $value): void + { + $this->setBasePathDirect($value); + } + + /** + * @return bool Whether CORS headers are emitted. Defaults to false. + */ + public function getEnableCors(): bool + { + return $this->getEnableCorsDirect(); + } + + /** + * @param bool|string $value Whether to enable CORS headers. + */ + public function setEnableCors(bool|string $value): void + { + $this->setEnableCorsDirect(TPropertyValue::ensureBoolean($value)); + } + + /** + * @return string `Access-Control-Allow-Origin` value. Defaults to `'*'`. + */ + public function getAllowOrigin(): string + { + return $this->getAllowOriginDirect(); + } + + /** + * Sets the allowed CORS origin(s). + * Use `'*'` to permit any origin (not compatible with credentials). + * @param string $value Origin value or `'*'`. + */ + public function setAllowOrigin(string $value): void + { + $this->setAllowOriginDirect($value); + } + + /** + * @return string Comma-separated `Access-Control-Allow-Methods` value. + */ + public function getAllowMethods(): string + { + return $this->getAllowMethodsDirect(); + } + + /** + * @param string $value Comma-separated list of allowed HTTP methods. + */ + public function setAllowMethods(string $value): void + { + $this->setAllowMethodsDirect($value); + } + + /** + * @return string Comma-separated `Access-Control-Allow-Headers` value. + */ + public function getAllowHeaders(): string + { + return $this->getAllowHeadersDirect(); + } + + /** + * @param string $value Comma-separated list of allowed request headers. + */ + public function setAllowHeaders(string $value): void + { + $this->setAllowHeadersDirect($value); + } + + /** + * @return bool Whether `Access-Control-Allow-Credentials: true` is emitted. Defaults to false. + */ + public function getAllowCredentials(): bool + { + return $this->getAllowCredentialsDirect(); + } + + /** + * When true, `Access-Control-Allow-Credentials: true` is sent. Requires + * an explicit {@see setAllowOrigin AllowOrigin} — combining credentials + * with the wildcard `'*'` origin raises a configuration error. + * @param bool|string $value + */ + public function setAllowCredentials($value): void + { + $this->setAllowCredentialsDirect(TPropertyValue::ensureBoolean($value)); + } + + /** + * @return int Preflight cache duration in seconds. Defaults to 86400. + */ + public function getMaxAge(): int + { + return $this->getMaxAgeDirect(); + } + + /** + * @param int|string $value Preflight cache duration in seconds. + */ + public function setMaxAge($value): void + { + $this->setMaxAgeDirect(TPropertyValue::ensureInteger($value)); + } + + /** + * @return bool Whether internal error details are included in 500 responses. + */ + public function getExposeErrors(): bool + { + return $this->getExposeErrorsDirect(); + } + + /** + * When true, uncaught exception messages are included in 500 error responses. + * Defaults to true in Debug application mode; false otherwise. + * @param bool|string $value + */ + public function setExposeErrors($value): void + { + $this->setExposeErrorsDirect(TPropertyValue::ensureBoolean($value)); + } +} diff --git a/framework/classes.php b/framework/classes.php index 797ece421..32cdbfae2 100644 --- a/framework/classes.php +++ b/framework/classes.php @@ -418,6 +418,10 @@ 'TJsonService' => 'Prado\Web\Services\TJsonService', 'TPageConfiguration' => 'Prado\Web\Services\TPageConfiguration', 'TPageService' => 'Prado\Web\Services\TPageService', +'TRestException' => 'Prado\Web\Services\Rest\TRestException', +'TRestPagination' => 'Prado\Web\Services\Rest\TRestPagination', +'TRestResource' => 'Prado\Web\Services\Rest\TRestResource', +'TRestService' => 'Prado\Web\Services\Rest\TRestService', 'TRpcApiProvider' => 'Prado\Web\Services\TRpcApiProvider', 'TRpcException' => 'Prado\Web\Services\TRpcException', 'TRpcProtocol' => 'Prado\Web\Services\TRpcProtocol', diff --git a/tests/unit/Web/Services/TRestExceptionTest.php b/tests/unit/Web/Services/TRestExceptionTest.php new file mode 100644 index 000000000..24900d854 --- /dev/null +++ b/tests/unit/Web/Services/TRestExceptionTest.php @@ -0,0 +1,314 @@ +assertSame(404, $e->getStatusCode()); + } + + public function testConstructorDefaultsTitleToHttpReason(): void + { + $e = new TRestException(404); + $this->assertSame('Not Found', $e->getTitle()); + + $e2 = new TRestException(422); + $this->assertSame('Unprocessable Entity', $e2->getTitle()); + + $e3 = new TRestException(500); + $this->assertSame('Internal Server Error', $e3->getTitle()); + } + + public function testConstructorUsesCustomTitle(): void + { + $e = new TRestException(404, 'Custom Title'); + $this->assertSame('Custom Title', $e->getTitle()); + } + + public function testConstructorUnknownStatusCodeFallsBackToError(): void + { + $e = new TRestException(418); + $this->assertSame('Error', $e->getTitle()); + } + + public function testConstructorSetsDetail(): void + { + $e = new TRestException(404, '', 'Resource not found.'); + $this->assertSame('Resource not found.', $e->getDetail()); + } + + public function testConstructorSetsErrors(): void + { + $errors = ['email' => ['Required.'], 'name' => ['Too long.']]; + $e = new TRestException(422, '', '', $errors); + $this->assertSame($errors, $e->getErrors()); + } + + public function testConstructorDefaultsDetailAndErrorsToEmpty(): void + { + $e = new TRestException(400); + $this->assertSame('', $e->getDetail()); + $this->assertSame([], $e->getErrors()); + } + + public function testIsThrowable(): void + { + $this->expectException(TRestException::class); + throw new TRestException(500); + } + + // ── toArray ──────────────────────────────────────────────────────────────── + + public function testToArrayAlwaysIncludesStatusAndTitle(): void + { + $arr = (new TRestException(403))->toArray(); + $this->assertArrayHasKey('status', $arr); + $this->assertArrayHasKey('title', $arr); + $this->assertSame(403, $arr['status']); + $this->assertSame('Forbidden', $arr['title']); + } + + public function testToArrayOmitsDetailWhenEmpty(): void + { + $arr = (new TRestException(404))->toArray(); + $this->assertArrayNotHasKey('detail', $arr); + } + + public function testToArrayIncludesDetailWhenSet(): void + { + $arr = (new TRestException(404, '', 'Not here.'))->toArray(); + $this->assertSame('Not here.', $arr['detail']); + } + + public function testToArrayOmitsErrorsWhenEmpty(): void + { + $arr = (new TRestException(422))->toArray(); + $this->assertArrayNotHasKey('errors', $arr); + } + + public function testToArrayIncludesErrorsWhenSet(): void + { + $errors = ['field' => ['msg']]; + $arr = (new TRestException(422, '', '', $errors))->toArray(); + $this->assertSame($errors, $arr['errors']); + } + + // ── Static factory methods ───────────────────────────────────────────────── + + public function testBadRequest(): void + { + $e = TRestException::badRequest('Bad input.'); + $this->assertSame(400, $e->getStatusCode()); + $this->assertSame('Bad Request', $e->getTitle()); + $this->assertSame('Bad input.', $e->getDetail()); + } + + public function testUnauthorized(): void + { + $e = TRestException::unauthorized('Please log in.'); + $this->assertSame(401, $e->getStatusCode()); + $this->assertSame('Unauthorized', $e->getTitle()); + } + + public function testForbidden(): void + { + $e = TRestException::forbidden(); + $this->assertSame(403, $e->getStatusCode()); + $this->assertSame('Forbidden', $e->getTitle()); + $this->assertSame('', $e->getDetail()); + } + + public function testNotFound(): void + { + $e = TRestException::notFound('User 42 not found.'); + $this->assertSame(404, $e->getStatusCode()); + $this->assertSame('Not Found', $e->getTitle()); + $this->assertSame('User 42 not found.', $e->getDetail()); + } + + public function testMethodNotAllowed(): void + { + $e = TRestException::methodNotAllowed(); + $this->assertSame(405, $e->getStatusCode()); + } + + public function testConflict(): void + { + $e = TRestException::conflict('Duplicate email.'); + $this->assertSame(409, $e->getStatusCode()); + $this->assertSame('Duplicate email.', $e->getDetail()); + } + + public function testUnsupportedMediaType(): void + { + $e = TRestException::unsupportedMediaType(); + $this->assertSame(415, $e->getStatusCode()); + } + + public function testUnprocessable(): void + { + $errors = ['email' => ['Invalid.']]; + $e = TRestException::unprocessable($errors, 'Validation failed.'); + $this->assertSame(422, $e->getStatusCode()); + $this->assertSame('Unprocessable Entity', $e->getTitle()); + $this->assertSame('Validation failed.', $e->getDetail()); + $this->assertSame($errors, $e->getErrors()); + } + + public function testUnprocessableWithNoDetail(): void + { + $e = TRestException::unprocessable(['x' => ['y']]); + $this->assertSame(422, $e->getStatusCode()); + $this->assertSame('', $e->getDetail()); + } + + public function testTooManyRequests(): void + { + $e = TRestException::tooManyRequests(); + $this->assertSame(429, $e->getStatusCode()); + } + + public function testInternalError(): void + { + $e = TRestException::internalError('Oops.'); + $this->assertSame(500, $e->getStatusCode()); + $this->assertSame('Oops.', $e->getDetail()); + } + + public function testFactoriesReturnInstanceOfTRestException(): void + { + $this->assertInstanceOf(TRestException::class, TRestException::badRequest()); + $this->assertInstanceOf(TRestException::class, TRestException::notFound()); + $this->assertInstanceOf(TRestException::class, TRestException::unprocessable([])); + } + + public function testGetCodeEqualsStatusCode(): void + { + $e = TRestException::notFound(); + $this->assertSame(404, $e->getCode()); + $this->assertSame($e->getStatusCode(), $e->getCode()); + } + + public function testToArrayWithDetailButNoErrors(): void + { + $e = new TRestException(400, '', 'Bad input'); + $arr = $e->toArray(); + $this->assertSame(400, $arr['status']); + $this->assertSame('Bad input', $arr['detail']); + $this->assertArrayNotHasKey('errors', $arr); + } + + public function testToArrayWithErrorsButNoDetail(): void + { + $e = TRestException::unprocessable(['email' => ['required']]); + $arr = $e->toArray(); + $this->assertArrayNotHasKey('detail', $arr); + $this->assertSame(['email' => ['required']], $arr['errors']); + } + + public function testToArrayWithBothDetailAndErrors(): void + { + $e = TRestException::unprocessable(['x' => ['bad']], 'invalid'); + $arr = $e->toArray(); + $this->assertSame('invalid', $arr['detail']); + $this->assertSame(['x' => ['bad']], $arr['errors']); + } + + // ── Factory status-to-title mapping ──────────────────────────────────────── + + /** + * @dataProvider factoryTitleProvider + */ + public function testFactoriesMapToExpectedStatusAndTitle(callable $factory, int $status, string $title): void + { + $e = $factory(); + $this->assertSame($status, $e->getStatusCode()); + $this->assertSame($title, $e->getTitle()); + } + + public static function factoryTitleProvider(): array + { + return [ + 'badRequest' => [fn () => TRestException::badRequest(), 400, 'Bad Request'], + 'unauthorized' => [fn () => TRestException::unauthorized(), 401, 'Unauthorized'], + 'forbidden' => [fn () => TRestException::forbidden(), 403, 'Forbidden'], + 'notFound' => [fn () => TRestException::notFound(), 404, 'Not Found'], + 'methodNotAllowed' => [fn () => TRestException::methodNotAllowed(), 405, 'Method Not Allowed'], + 'conflict' => [fn () => TRestException::conflict(), 409, 'Conflict'], + 'unsupportedMediaType' => [fn () => TRestException::unsupportedMediaType(), 415, 'Unsupported Media Type'], + 'unprocessable' => [fn () => TRestException::unprocessable(), 422, 'Unprocessable Entity'], + 'tooManyRequests' => [fn () => TRestException::tooManyRequests(), 429, 'Too Many Requests'], + 'internalError' => [fn () => TRestException::internalError(), 500, 'Internal Server Error'], + ]; + } + + public function testServiceUnavailableTitle(): void + { + $e = new TRestException(503); + $this->assertSame('Service Unavailable', $e->getTitle()); + } + + // ── toArray boundary: status + title only ────────────────────────────────── + + public function testToArrayWithOnlyStatusAndTitleHasNoDetailOrErrors(): void + { + $arr = (new TRestException(404))->toArray(); + $this->assertSame(['status' => 404, 'title' => 'Not Found'], $arr); + } + + public function testUnprocessableWithDefaultEmptyErrorsOmitsErrorsKey(): void + { + $arr = TRestException::unprocessable()->toArray(); + $this->assertArrayNotHasKey('errors', $arr); + } + + // ── Unknown / boundary status codes ──────────────────────────────────────── + + public function testUnknownStatusCodeFallsBackToGenericTitle(): void + { + $e = new TRestException(499); + $this->assertSame('Error', $e->getTitle()); + $this->assertSame(499, $e->getStatusCode()); + } + + public function testCodeEqualsStatusForMultipleCodes(): void + { + foreach ([400, 404, 422, 429, 500] as $code) { + $this->assertSame($code, (new TRestException($code))->getCode()); + } + } + + public function testWhitespaceOnlyTitleIsPreservedNotTreatedAsEmpty(): void + { + // Only an empty string triggers the reason-phrase fallback; ' ' is kept. + $e = new TRestException(404, ' '); + $this->assertSame(' ', $e->getTitle()); + } + + public function testUnicodeTitleAndDetailRoundTrip(): void + { + $e = new TRestException(400, 'Erreur héllo', 'détail café'); + $arr = $e->toArray(); + $this->assertSame('Erreur héllo', $arr['title']); + $this->assertSame('détail café', $arr['detail']); + } + + public function testThrowabilityPropagatesMessageAndCode(): void + { + try { + throw TRestException::notFound('gone'); + } catch (TRestException $e) { + $this->assertSame(404, $e->getStatusCode()); + $this->assertSame(404, $e->getCode()); + $this->assertSame('gone', $e->getDetail()); + } + } +} diff --git a/tests/unit/Web/Services/TRestPaginationTest.php b/tests/unit/Web/Services/TRestPaginationTest.php new file mode 100644 index 000000000..bca7efde6 --- /dev/null +++ b/tests/unit/Web/Services/TRestPaginationTest.php @@ -0,0 +1,374 @@ +getRequest(); + foreach (self::PAGINATION_KEYS as $key) { + $this->requestBackup[$key] = $request->itemAt($key); + } + } + + protected function tearDown(): void + { + $request = Prado::getApplication()->getRequest(); + foreach (self::PAGINATION_KEYS as $key) { + if ($this->requestBackup[$key] === null) { + $request->remove($key); + } else { + $request->add($key, $this->requestBackup[$key]); + } + } + } + + // ── Constructor ──────────────────────────────────────────────────────────── + + public function testConstructorDefaults(): void + { + $p = new TRestPagination(); + $this->assertSame(1, $p->getPage()); + $this->assertSame(20, $p->getPerPage()); + $this->assertSame(100, $p->getMaxPerPage()); + } + + public function testConstructorClampsPageToOne(): void + { + $p = new TRestPagination(0); + $this->assertSame(1, $p->getPage()); + + $p2 = new TRestPagination(-5); + $this->assertSame(1, $p2->getPage()); + } + + public function testConstructorClampsPerPageToOne(): void + { + $p = new TRestPagination(1, 0); + $this->assertSame(1, $p->getPerPage()); + } + + public function testConstructorClampsPerPageToMaxPerPage(): void + { + $p = new TRestPagination(1, 500, 100); + $this->assertSame(100, $p->getPerPage()); + } + + public function testConstructorAcceptsCustomMaxPerPage(): void + { + $p = new TRestPagination(1, 50, 200); + $this->assertSame(200, $p->getMaxPerPage()); + $this->assertSame(50, $p->getPerPage()); + } + + // ── Offset and Limit ─────────────────────────────────────────────────────── + + public function testGetOffsetPageOne(): void + { + $p = new TRestPagination(1, 20); + $this->assertSame(0, $p->getOffset()); + } + + public function testGetOffsetPageTwo(): void + { + $p = new TRestPagination(2, 20); + $this->assertSame(20, $p->getOffset()); + } + + public function testGetOffsetPageThree(): void + { + $p = new TRestPagination(3, 15); + $this->assertSame(30, $p->getOffset()); + } + + public function testGetLimitEqualsPerPage(): void + { + $p = new TRestPagination(1, 25); + $this->assertSame(25, $p->getLimit()); + } + + // ── toMeta ───────────────────────────────────────────────────────────────── + + public function testToMetaFirstPage(): void + { + $p = new TRestPagination(1, 20); + $meta = $p->toMeta(100); + + $this->assertSame(100, $meta['total']); + $this->assertSame(20, $meta['per_page']); + $this->assertSame(1, $meta['current_page']); + $this->assertSame(5, $meta['last_page']); + $this->assertSame(1, $meta['from']); + $this->assertSame(20, $meta['to']); + } + + public function testToMetaSecondPage(): void + { + $p = new TRestPagination(2, 20); + $meta = $p->toMeta(100); + + $this->assertSame(2, $meta['current_page']); + $this->assertSame(21, $meta['from']); + $this->assertSame(40, $meta['to']); + } + + public function testToMetaLastPartialPage(): void + { + $p = new TRestPagination(3, 20); + $meta = $p->toMeta(55); + + $this->assertSame(3, $meta['last_page']); + $this->assertSame(41, $meta['from']); + $this->assertSame(55, $meta['to']); // capped at total + } + + public function testToMetaEmptyCollection(): void + { + $p = new TRestPagination(1, 20); + $meta = $p->toMeta(0); + + $this->assertSame(0, $meta['total']); + $this->assertSame(1, $meta['last_page']); // at least 1 page + $this->assertNull($meta['from']); + $this->assertNull($meta['to']); + } + + public function testToMetaLastPageIsAtLeastOne(): void + { + $p = new TRestPagination(1, 20); + $meta = $p->toMeta(0); + $this->assertSame(1, $meta['last_page']); + } + + public function testToMetaExactlyOnePage(): void + { + $p = new TRestPagination(1, 10); + $meta = $p->toMeta(10); + $this->assertSame(1, $meta['last_page']); + $this->assertSame(10, $meta['to']); + } + + // ── paginate ─────────────────────────────────────────────────────────────── + + public function testPaginateWrapsDataAndMeta(): void + { + $p = new TRestPagination(1, 2); + $data = [['id' => 1], ['id' => 2]]; + $result = $p->paginate($data, 5); + + $this->assertArrayHasKey('data', $result); + $this->assertArrayHasKey('meta', $result); + $this->assertSame($data, $result['data']); + $this->assertSame(5, $result['meta']['total']); + $this->assertSame(3, $result['meta']['last_page']); + } + + public function testPaginateEmptyData(): void + { + $p = new TRestPagination(1, 20); + $result = $p->paginate([], 0); + $this->assertSame([], $result['data']); + $this->assertSame(0, $result['meta']['total']); + } + + // ── fromRequest ──────────────────────────────────────────────────────────── + + public function testFromRequestReadsPageAndPerPageFromGet(): void + { + $request = Prado::getApplication()->getRequest(); + $request->add('page', '3'); + $request->add('per_page', '15'); + + $p = TRestPagination::fromRequest($request); + + $this->assertSame(3, $p->getPage()); + $this->assertSame(15, $p->getPerPage()); + } + + public function testFromRequestDefaultsWhenParamsAbsent(): void + { + $request = Prado::getApplication()->getRequest(); + $request->remove('page'); + $request->remove('per_page'); + + $p = TRestPagination::fromRequest($request, 25, 50); + + $this->assertSame(1, $p->getPage()); + $this->assertSame(25, $p->getPerPage()); + $this->assertSame(50, $p->getMaxPerPage()); + } + + public function testFromRequestCapsPerPageAtMax(): void + { + $request = Prado::getApplication()->getRequest(); + $request->add('per_page', '9999'); + + $p = TRestPagination::fromRequest($request, 20, 50); + + $this->assertSame(50, $p->getPerPage()); + } + + public function testFromRequestClampsPageBelowOneToOne(): void + { + $request = Prado::getApplication()->getRequest(); + $request->add('page', '0'); + + $p = TRestPagination::fromRequest($request); + + $this->assertSame(1, $p->getPage()); + } + + public function testFromRequestUsesCustomDefaultPerPage(): void + { + $p = TRestPagination::fromRequest(null, 50); + $this->assertSame(50, $p->getPerPage()); + } + + public function testToMetaWithZeroTotalReturnsNullFromAndTo(): void + { + $p = new TRestPagination(1, 20); + $meta = $p->toMeta(0); + $this->assertSame(0, $meta['total']); + $this->assertNull($meta['from']); + $this->assertNull($meta['to']); + $this->assertSame(1, $meta['last_page']); + } + + // ── Integer-overflow guard (HIGH regression) ─────────────────────────────── + + public function testGetOffsetDoesNotOverflowForHugePage(): void + { + // A crafted page near PHP_INT_MAX must not promote the offset to a float + // and throw a TypeError on the int return. + $p = new TRestPagination(PHP_INT_MAX, 100, 100); + $offset = $p->getOffset(); + $this->assertIsInt($offset); + $this->assertLessThanOrEqual(PHP_INT_MAX, $offset); + } + + public function testFromRequestHugePageDoesNotCrash(): void + { + $request = Prado::getApplication()->getRequest(); + $request->add('page', '99999999999999999999'); + $p = TRestPagination::fromRequest($request); + $this->assertIsInt($p->getOffset()); + $meta = $p->toMeta(1000); // must not throw + $this->assertIsInt($meta['last_page']); + } + + // ── setMaxPerPage / clamping ─────────────────────────────────────────────── + + public function testConstructorClampsMaxPerPageToOne(): void + { + // maxPerPage below 1 clamps to 1, which then caps perPage at 1. + $p = new TRestPagination(1, 50, 0); + $this->assertSame(1, $p->getMaxPerPage()); + $this->assertSame(1, $p->getPerPage()); + } + + // ── fromRequest edge cases ───────────────────────────────────────────────── + + public function testFromRequestPerPageBelowOneClampsToOne(): void + { + $request = Prado::getApplication()->getRequest(); + $request->add('per_page', '0'); + $p = TRestPagination::fromRequest($request); + $this->assertSame(1, $p->getPerPage()); + + $request->add('per_page', '-10'); + $p2 = TRestPagination::fromRequest($request); + $this->assertSame(1, $p2->getPerPage()); + } + + public function testFromRequestNonNumericParamsCoerceToFloor(): void + { + // (int) 'abc' === 0, then clamped to 1 for both page and per_page. + $request = Prado::getApplication()->getRequest(); + $request->add('page', 'abc'); + $request->add('per_page', 'xyz'); + $p = TRestPagination::fromRequest($request); + $this->assertSame(1, $p->getPage()); + $this->assertSame(1, $p->getPerPage()); + } + + public function testFromRequestEmptyStringParamsClampToOne(): void + { + // An empty string is present (not absent), so the default is bypassed: + // (int) '' === 0 → clamped to 1, rather than the documented default of 20. + $request = Prado::getApplication()->getRequest(); + $request->add('page', ''); + $request->add('per_page', ''); + $p = TRestPagination::fromRequest($request, 20, 100); + $this->assertSame(1, $p->getPage()); + $this->assertSame(1, $p->getPerPage()); + } + + public function testFromRequestNumericStringWithSuffixTruncates(): void + { + // (int) '15abc' === 15 and (int) '2.9' === 2 — PHP leading-numeric cast. + $request = Prado::getApplication()->getRequest(); + $request->add('page', '2.9'); + $request->add('per_page', '15abc'); + $p = TRestPagination::fromRequest($request); + $this->assertSame(2, $p->getPage()); + $this->assertSame(15, $p->getPerPage()); + } + + public function testFromRequestArrayParamCoercesToOne(): void + { + // An array-valued parameter casts to 1 (PHP (int)[...] === 1) and clamps. + $request = Prado::getApplication()->getRequest(); + $request->add('page', ['x', 'y']); + $p = TRestPagination::fromRequest($request); + $this->assertSame(1, $p->getPage()); + } + + public function testFromRequestNullRequestReadsApplicationRequest(): void + { + $request = Prado::getApplication()->getRequest(); + $request->add('page', '4'); + $p = TRestPagination::fromRequest(null); + $this->assertSame(4, $p->getPage()); + } + + // ── toMeta out-of-range page ─────────────────────────────────────────────── + + public function testToMetaPageBeyondLastPageProducesFromBeyondTotal(): void + { + // page 10 of a 2-page set: from exceeds total and to is capped at total. + $p = new TRestPagination(10, 20); + $meta = $p->toMeta(30); + $this->assertSame(2, $meta['last_page']); + $this->assertSame(181, $meta['from']); + $this->assertSame(30, $meta['to']); + } + + public function testToMetaLastPageRoundingBoundary(): void + { + $this->assertSame(4, (new TRestPagination(1, 10))->toMeta(31)['last_page']); + $this->assertSame(3, (new TRestPagination(1, 10))->toMeta(30)['last_page']); + $this->assertSame(3, (new TRestPagination(1, 10))->toMeta(29)['last_page']); + } + + // ── Direct accessors exercised independently ─────────────────────────────── + + public function testAccessorsReflectConstructorValues(): void + { + $p = new TRestPagination(3, 25, 200); + $this->assertSame(3, $p->getPage()); + $this->assertSame(25, $p->getPerPage()); + $this->assertSame(25, $p->getLimit()); + $this->assertSame(200, $p->getMaxPerPage()); + $this->assertSame(50, $p->getOffset()); + } +} diff --git a/tests/unit/Web/Services/TRestResourceTest.php b/tests/unit/Web/Services/TRestResourceTest.php new file mode 100644 index 000000000..cf1d69553 --- /dev/null +++ b/tests/unit/Web/Services/TRestResourceTest.php @@ -0,0 +1,1113 @@ + 1], ['id' => 2]]; + } + + public function show(string $id): array + { + if ($id === '0') { + $this->notFound("Item {$id} does not exist."); + } + return ['id' => $id]; + } +} + +/** + * Full CRUD resource for validation and status helper tests. + */ +class TestCrudResource extends TRestResource +{ + public function store(): array + { + $data = $this->validateBody([ + 'name' => 'required|string|max:10', + 'age' => 'required|integer|min:0|max:150', + 'email' => 'required|email', + ]); + return $this->created($data); + } + + public function destroy(string $id): void + { + $this->noContent(); + } +} + +/** + * Resource that exercises every helper method accessible to subclasses. + */ +class TestHelperResource extends TRestResource +{ + public function index(): array + { + return $this->accepted(['queued' => true]); + } + + public function show(): void + { + $this->header('X-Custom', 'value')->noContent(); + } +} + +/** + * Resource that enforces auth. + */ +class TestAuthResource extends TRestResource +{ + private bool $allowAll = false; + + public function setAllowAll(bool $v): void + { + $this->allowAll = $v; + } + + public function authorize(string $method): void + { + if (!$this->allowAll) { + $this->unauthorized('Must be authenticated.'); + } + } + + public function index(): array + { + return []; + } +} + +// ── Test class ───────────────────────────────────────────────────────────────── + +/** + * Tests for TRestResource. + */ +class TRestResourceTest extends PHPUnit\Framework\TestCase +{ + /** @var array Snapshot of $_SERVER taken before each test. */ + private array $serverBackup = []; + + protected function setUp(): void + { + // Trigger THttpRequest::init() now so it doesn't later overwrite + // the $_SERVER values our tests set up (init resets REQUEST_METHOD + // to 'GET' in CLI mode). + Prado::getApplication()->getRequest(); + $this->serverBackup = $_SERVER; + } + + protected function tearDown(): void + { + $_SERVER = $this->serverBackup; + } + + private function makeResource(string $class, array $pathParams = []): TRestResource + { + /** @var TRestResource $r */ + $r = new $class(); + $r->setPathParameters($pathParams); + return $r; + } + + // ── Default 405 behaviour ────────────────────────────────────────────────── + + public function testUndeclaredVerbsThrow405(): void + { + $r = $this->makeResource(TestReadOnlyResource::class); + + foreach (['doStore', 'doUpdate', 'doPatch', 'doDestroy'] as $method) { + try { + $r->$method(); + $this->fail("Expected TRestException for {$method}()"); + } catch (TRestException $e) { + $this->assertSame(405, $e->getStatusCode(), "Wrong status for {$method}()"); + } + } + } + + // ── Path parameters ──────────────────────────────────────────────────────── + + public function testGetPathParameters(): void + { + $r = $this->makeResource(TestReadOnlyResource::class, ['id' => '42', 'userId' => '7']); + $this->assertSame(['id' => '42', 'userId' => '7'], $r->getPathParameters()); + } + + public function testGetPathParameterByName(): void + { + $r = $this->makeResource(TestReadOnlyResource::class, ['id' => '99']); + $this->assertSame('99', $r->getPathParameter('id')); + } + + public function testGetPathParameterReturnsDefaultWhenAbsent(): void + { + $r = $this->makeResource(TestReadOnlyResource::class); + $this->assertSame('fallback', $r->getPathParameter('missing', 'fallback')); + } + + // ── Status helpers ───────────────────────────────────────────────────────── + + public function testDefaultStatusCodeIs200(): void + { + $r = $this->makeResource(TestReadOnlyResource::class); + $this->assertSame(200, $r->getStatusCode()); + } + + public function testCreatedSets201AndReturnsData(): void + { + $r = $this->makeResource(TestCrudResource::class); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + + // Provide a valid body via input stream mock + $data = ['name' => 'Alice', 'age' => 30, 'email' => 'alice@example.com']; + // We simulate getBody() returning validated data by directly calling validateBody via store + // We must mock php://input — use a workaround: set _parsedBody via reflection + $ref = new ReflectionProperty(TRestResource::class, '_parsedBody'); + $ref->setAccessible(true); + $ref->setValue($r, $data); + + $result = $r->store(); + $this->assertSame(201, $r->getStatusCode()); + $this->assertSame($data, $result); + } + + public function testNoContentSets204(): void + { + $r = $this->makeResource(TestCrudResource::class, ['id' => '5']); + $_SERVER['REQUEST_METHOD'] = 'DELETE'; + $r->destroy('5'); + $this->assertSame(204, $r->getStatusCode()); + } + + public function testAcceptedSets202AndReturnsData(): void + { + $r = $this->makeResource(TestHelperResource::class); + $result = $r->index(); + $this->assertSame(202, $r->getStatusCode()); + $this->assertSame(['queued' => true], $result); + } + + public function testHeaderAddsToResponseHeaders(): void + { + $r = $this->makeResource(TestHelperResource::class); + $r->show(); + $this->assertSame(['X-Custom' => 'value'], $r->getResponseHeaders()); + } + + // ── Exception helpers ────────────────────────────────────────────────────── + + public function testNotFoundThrows404(): void + { + $r = $this->makeResource(TestReadOnlyResource::class, ['id' => '0']); + $this->expectException(TRestException::class); + $this->expectExceptionCode(404); + $r->show('0'); + } + + public function testAbortThrowsWithGivenStatus(): void + { + $r = $this->makeResource(TestReadOnlyResource::class); + $called = false; + try { + // Use reflection to call protected abort + $ref = new ReflectionMethod($r, 'abort'); + $ref->setAccessible(true); + $ref->invoke($r, 409, 'Conflict detail'); + } catch (TRestException $e) { + $called = true; + $this->assertSame(409, $e->getStatusCode()); + $this->assertSame('Conflict detail', $e->getDetail()); + } + $this->assertTrue($called); + } + + // ── Auth hook ────────────────────────────────────────────────────────────── + + public function testAuthorizeDoesNothingByDefault(): void + { + $r = $this->makeResource(TestReadOnlyResource::class); + // Should not throw + $r->authorize('index'); + $this->assertTrue(true); + } + + public function testAuthorizeThrowsWhenDenied(): void + { + $r = new TestAuthResource(); + $this->expectException(TRestException::class); + $r->authorize('index'); + } + + public function testAuthorizePassesWhenAllowed(): void + { + $r = new TestAuthResource(); + $r->setAllowAll(true); + $r->authorize('index'); + $this->assertTrue(true); + } + + // ── Validation ───────────────────────────────────────────────────────────── + + private function makeValidatingResource(array $body): TestCrudResource + { + $r = new TestCrudResource(); + $ref = new ReflectionProperty(TRestResource::class, '_parsedBody'); + $ref->setAccessible(true); + $ref->setValue($r, $body); + return $r; + } + + public function testValidatePassesWithCorrectData(): void + { + $r = $this->makeValidatingResource([ + 'name' => 'Alice', + 'age' => 30, + 'email' => 'alice@example.com', + ]); + $result = $r->store(); + $this->assertSame('Alice', $result['name']); + $this->assertSame(30, $result['age']); + } + + public function testValidateThrows422OnMissingRequired(): void + { + $r = $this->makeValidatingResource(['name' => 'Bob']); + try { + $r->store(); + $this->fail('Expected TRestException'); + } catch (TRestException $e) { + $this->assertSame(422, $e->getStatusCode()); + $errors = $e->getErrors(); + $this->assertArrayHasKey('age', $errors); + $this->assertArrayHasKey('email', $errors); + } + } + + public function testValidateThrows422OnInvalidEmail(): void + { + $r = $this->makeValidatingResource([ + 'name' => 'Bob', + 'age' => 25, + 'email' => 'not-an-email', + ]); + try { + $r->store(); + $this->fail('Expected TRestException'); + } catch (TRestException $e) { + $this->assertSame(422, $e->getStatusCode()); + $this->assertArrayHasKey('email', $e->getErrors()); + } + } + + public function testValidateThrows422OnStringTooLong(): void + { + $r = $this->makeValidatingResource([ + 'name' => str_repeat('a', 11), // max:10 + 'age' => 25, + 'email' => 'a@b.com', + ]); + try { + $r->store(); + $this->fail('Expected TRestException'); + } catch (TRestException $e) { + $this->assertSame(422, $e->getStatusCode()); + $this->assertArrayHasKey('name', $e->getErrors()); + } + } + + public function testValidateThrows422OnIntegerOutOfRange(): void + { + $r = $this->makeValidatingResource([ + 'name' => 'Bob', + 'age' => 200, // max:150 + 'email' => 'a@b.com', + ]); + try { + $r->store(); + $this->fail('Expected TRestException'); + } catch (TRestException $e) { + $this->assertSame(422, $e->getStatusCode()); + $this->assertArrayHasKey('age', $e->getErrors()); + } + } + + public function testValidateCoercesNumericStringToInteger(): void + { + // Expose validate() via a fresh resource + $r = new class () extends TRestResource { + public function callValidate(array $data, array $rules): array + { + return $this->validate($data, $rules); + } + }; + $result = $r->callValidate(['count' => '7'], ['count' => 'integer']); + $this->assertSame(7, $result['count']); + } + + public function testValidateNullableFieldAcceptsNull(): void + { + $r = new class () extends TRestResource { + public function callValidate(array $data, array $rules): array + { + return $this->validate($data, $rules); + } + }; + $result = $r->callValidate(['bio' => null], ['bio' => 'nullable|string']); + $this->assertNull($result['bio']); + } + + public function testValidateInRuleAcceptsValidValue(): void + { + $r = new class () extends TRestResource { + public function callValidate(array $data, array $rules): array + { + return $this->validate($data, $rules); + } + }; + $result = $r->callValidate(['status' => 'active'], ['status' => 'in:active,inactive,pending']); + $this->assertSame('active', $result['status']); + } + + public function testValidateInRuleRejectsInvalidValue(): void + { + $r = new class () extends TRestResource { + public function callValidate(array $data, array $rules): array + { + return $this->validate($data, $rules); + } + }; + try { + $r->callValidate(['status' => 'deleted'], ['status' => 'in:active,inactive']); + $this->fail('Expected TRestException'); + } catch (TRestException $e) { + $this->assertSame(422, $e->getStatusCode()); + } + } + + // ── Input helpers ────────────────────────────────────────────────────────── + + public function testOnlyReturnsSubsetOfBody(): void + { + $r = new class () extends TRestResource { + public function callOnly(array $keys): array + { + return $this->only($keys); + } + }; + $ref = new ReflectionProperty(TRestResource::class, '_parsedBody'); + $ref->setAccessible(true); + $ref->setValue($r, ['a' => 1, 'b' => 2, 'c' => 3]); + + $result = $r->callOnly(['a', 'c']); + $this->assertSame(['a' => 1, 'c' => 3], $result); + } + + public function testExceptRemovesKeysFromBody(): void + { + $r = new class () extends TRestResource { + public function callExcept(array $keys): array + { + return $this->except($keys); + } + }; + $ref = new ReflectionProperty(TRestResource::class, '_parsedBody'); + $ref->setAccessible(true); + $ref->setValue($r, ['a' => 1, 'b' => 2, 'c' => 3]); + + $result = $r->callExcept(['b']); + $this->assertSame(['a' => 1, 'c' => 3], $result); + } + + // ── getBody (covers JSON, form-POST, form-PUT/PATCH, and GET paths) ──────── + + private function bodyResource(string $rawBody): TRestResource + { + return new class ($rawBody) extends TRestResource { + public function __construct(private string $rawBody) + { + parent::__construct(); + } + protected function readRawRequestBody(): string + { + return $this->rawBody; + } + public function callGetBody(): array + { + return $this->getBody(); + } + }; + } + + public function testGetBodyJsonPost(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $r = $this->bodyResource('{"name":"Alice","age":30}'); + $this->assertSame(['name' => 'Alice', 'age' => 30], $r->callGetBody()); + } + + public function testGetBodyJsonPutAndPatch(): void + { + foreach (['PUT', 'PATCH'] as $verb) { + $_SERVER['REQUEST_METHOD'] = $verb; + $_SERVER['CONTENT_TYPE'] = 'application/json; charset=utf-8'; + $r = $this->bodyResource('{"v":1}'); + $this->assertSame(['v' => 1], $r->callGetBody(), "verb={$verb}"); + } + } + + public function testGetBodyMalformedJsonThrows400(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $r = $this->bodyResource('not json'); + $this->expectException(\Prado\Web\Services\Rest\TRestException::class); + $this->expectExceptionCode(400); + $r->callGetBody(); + } + + public function testGetBodyJsonEmptyBodyYieldsEmptyArray(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $r = $this->bodyResource(''); + $this->assertSame([], $r->callGetBody()); + } + + public function testGetBodyFormPostReadsSuperglobal(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_POST = ['x' => '1', 'y' => 'two']; + $r = $this->bodyResource(''); // raw stream irrelevant for POST form + $this->assertSame(['x' => '1', 'y' => 'two'], $r->callGetBody()); + $_POST = []; + } + + public function testGetBodyFormPutParsesRawStream(): void + { + // Regression: PHP's $_POST is not populated for PUT — must parse php://input. + $_SERVER['REQUEST_METHOD'] = 'PUT'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $r = $this->bodyResource('name=Bob&age=25'); + $this->assertSame(['name' => 'Bob', 'age' => '25'], $r->callGetBody()); + } + + public function testGetBodyFormPatchParsesRawStream(): void + { + $_SERVER['REQUEST_METHOD'] = 'PATCH'; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $r = $this->bodyResource('role=admin'); + $this->assertSame(['role' => 'admin'], $r->callGetBody()); + } + + public function testGetBodyGetReturnsEmpty(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $r = $this->bodyResource('ignored'); + $this->assertSame([], $r->callGetBody()); + } + + public function testGetBodyIsCachedAfterFirstCall(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $r = $this->bodyResource('{"a":1}'); + $first = $r->callGetBody(); + $second = $r->callGetBody(); + $this->assertSame($first, $second); + } + + // ── input/query/hasInput ─────────────────────────────────────────────────── + + private function inputResource(array $body = []): TRestResource + { + $r = new class () extends TRestResource { + public function callInput(string $k, mixed $d = null): mixed + { + return $this->input($k, $d); + } + public function callQuery(string $k, mixed $d = null): mixed + { + return $this->query($k, $d); + } + public function callHasInput(string $k): bool + { + return $this->hasInput($k); + } + public function callOnly(array $keys): array + { + return $this->only($keys); + } + public function callExcept(array $keys): array + { + return $this->except($keys); + } + }; + $ref = new ReflectionProperty(TRestResource::class, '_parsedBody'); + $ref->setAccessible(true); + $ref->setValue($r, $body); + return $r; + } + + public function testInputReadsFromBody(): void + { + $r = $this->inputResource(['name' => 'Alice']); + $this->assertSame('Alice', $r->callInput('name')); + } + + public function testInputFallsBackToQueryString(): void + { + $_GET['filter'] = 'active'; + try { + $r = $this->inputResource([]); + $this->assertSame('active', $r->callInput('filter')); + } finally { + unset($_GET['filter']); + } + } + + public function testInputReturnsDefaultWhenAbsent(): void + { + $r = $this->inputResource([]); + $this->assertSame('fallback', $r->callInput('missing', 'fallback')); + } + + public function testQueryReadsFromQueryStringOnly(): void + { + $_GET['q'] = 'search'; + try { + $r = $this->inputResource(['q' => 'from-body']); + $this->assertSame('search', $r->callQuery('q')); + } finally { + unset($_GET['q']); + } + } + + public function testQueryIgnoresNonQueryRequestParameters(): void + { + // Routing parameters and form-POST fields live in THttpRequest's merged + // map but are not query-string values — query() must not see them. + $request = Prado::getApplication()->getRequest(); + $request->add('routed', 'value'); + try { + $r = $this->inputResource([]); + $this->assertNull($r->callQuery('routed')); + } finally { + $request->remove('routed'); + } + } + + public function testQueryReturnsDefaultWhenAbsent(): void + { + $r = $this->inputResource([]); + $this->assertSame('def', $r->callQuery('missing', 'def')); + } + + public function testHasInputTrueForBody(): void + { + $r = $this->inputResource(['x' => 1]); + $this->assertTrue($r->callHasInput('x')); + } + + public function testHasInputTrueForQuery(): void + { + $_GET['z'] = '1'; + try { + $r = $this->inputResource([]); + $this->assertTrue($r->callHasInput('z')); + } finally { + unset($_GET['z']); + } + } + + public function testHasInputFalseWhenAbsent(): void + { + $r = $this->inputResource([]); + $this->assertFalse($r->callHasInput('missing')); + } + + // ── Remaining validation rules ───────────────────────────────────────────── + + private function validator(): TRestResource + { + return new class () extends TRestResource { + public function v(array $data, array $rules): array + { + return $this->validate($data, $rules); + } + }; + } + + public function testValidateBooleanRulePasses(): void + { + $v = $this->validator(); + foreach ([true, false, 1, 0, '1', '0', 'true', 'false'] as $val) { + $result = $v->v(['flag' => $val], ['flag' => 'boolean']); + $this->assertIsBool($result['flag']); + } + } + + public function testValidateBooleanRuleRejectsNonBoolean(): void + { + $this->expectException(TRestException::class); + $this->validator()->v(['flag' => 'yes'], ['flag' => 'boolean']); + } + + public function testValidateArrayRulePassesAndRejects(): void + { + $v = $this->validator(); + $this->assertSame(['tags' => ['a', 'b']], $v->v(['tags' => ['a', 'b']], ['tags' => 'array'])); + + $this->expectException(TRestException::class); + $v->v(['tags' => 'not-array'], ['tags' => 'array']); + } + + public function testValidateUrlRulePassesAndRejects(): void + { + $v = $this->validator(); + $this->assertSame(['site' => 'https://example.com'], $v->v(['site' => 'https://example.com'], ['site' => 'url'])); + + $this->expectException(TRestException::class); + $v->v(['site' => 'not a url'], ['site' => 'url']); + } + + public function testValidateFloatAndNumericCoerce(): void + { + $v = $this->validator(); + $result = $v->v(['p' => '3.14'], ['p' => 'numeric']); + $this->assertSame(3.14, $result['p']); + $result = $v->v(['p' => '2'], ['p' => 'float']); + $this->assertSame(2.0, $result['p']); + } + + public function testValidateNumericRejectsNonNumeric(): void + { + $this->expectException(TRestException::class); + $this->validator()->v(['p' => 'abc'], ['p' => 'numeric']); + } + + public function testValidateMinAndMaxOnNumber(): void + { + $v = $this->validator(); + // min on number + $result = $v->v(['n' => 10], ['n' => 'integer|min:5']); + $this->assertSame(10, $result['n']); + try { + $v->v(['n' => 1], ['n' => 'integer|min:5']); + $this->fail('expected min violation'); + } catch (TRestException $e) { + $this->assertSame(422, $e->getStatusCode()); + } + // min on string length + $result = $v->v(['s' => 'hello'], ['s' => 'string|min:3']); + $this->assertSame('hello', $result['s']); + try { + $v->v(['s' => 'hi'], ['s' => 'string|min:3']); + $this->fail('expected string min violation'); + } catch (TRestException $e) { + $this->assertSame(422, $e->getStatusCode()); + } + } + + public function testValidateNullableSkipsTypeRulesWhenNull(): void + { + $v = $this->validator(); + // nullable + integer with null should not error + $result = $v->v(['n' => null], ['n' => 'nullable|integer']); + $this->assertNull($result['n']); + } + + public function testValidateRequiredAndNullableTogether(): void + { + // When both are present and value is null, required wins (error). + $this->expectException(TRestException::class); + $this->validator()->v(['x' => null], ['x' => 'required|nullable|string']); + } + + public function testValidateOmitsUndeclaredFields(): void + { + $result = $this->validator()->v( + ['name' => 'Alice', 'secret' => 'leak'], + ['name' => 'string'] + ); + $this->assertSame(['name' => 'Alice'], $result); + } + + public function testValidateUnknownRuleThrowsConfigurationException(): void + { + // A typo in a rule name is a developer error, not a 422 for the client. + $this->expectException(TConfigurationException::class); + $this->validator()->v(['name' => 'Alice'], ['name' => 'requried|string']); + } + + // ── Remaining exception helpers ──────────────────────────────────────────── + + public function testForbiddenThrows403(): void + { + $r = new class () extends TRestResource { + public function go(): void + { + $this->forbidden('nope'); + } + }; + try { + $r->go(); + $this->fail('expected exception'); + } catch (TRestException $e) { + $this->assertSame(403, $e->getStatusCode()); + $this->assertSame('nope', $e->getDetail()); + } + } + + public function testConflictThrows409(): void + { + $r = new class () extends TRestResource { + public function go(): void + { + $this->conflict('dup'); + } + }; + try { + $r->go(); + $this->fail('expected exception'); + } catch (TRestException $e) { + $this->assertSame(409, $e->getStatusCode()); + } + } + + public function testUnprocessableThrowsWithFieldErrors(): void + { + $r = new class () extends TRestResource { + public function go(): void + { + $this->unprocessable(['email' => ['bad']], 'invalid'); + } + }; + try { + $r->go(); + $this->fail('expected exception'); + } catch (TRestException $e) { + $this->assertSame(422, $e->getStatusCode()); + $this->assertSame(['email' => ['bad']], $e->getErrors()); + } + } + + // ── __call delegates to overridden methods ───────────────────────────────── + + public function testCallDelegatesToOverriddenConventionMethod(): void + { + // TestReadOnlyResource overrides index(), not doIndex(). __call on doIndex must throw 405. + $r = new TestReadOnlyResource(); + try { + $r->doIndex(); + $this->fail('expected 405'); + } catch (TRestException $e) { + $this->assertSame(405, $e->getStatusCode()); + } + } + + public function testCallUnknownMethodThrowsBadMethodCall(): void + { + $r = new TestReadOnlyResource(); + $this->expectException(BadMethodCallException::class); + $r->somethingTotallyMadeUp(); + } + + // ── Validation edge cases (regressions + per-rule coverage) ──────────────── + + public function testValidateIntegerRejectsDoubleMinus(): void + { + // Regression: '--5' previously passed ctype_digit(ltrim($v, '-')) and + // got cast to 0. filter_var-based check rejects it cleanly. + $this->expectException(TRestException::class); + $this->validator()->v(['n' => '--5'], ['n' => 'integer']); + } + + public function testValidateIntegerRejectsTrailingNoise(): void + { + $this->expectException(TRestException::class); + $this->validator()->v(['n' => '5abc'], ['n' => 'integer']); + } + + public function testValidateIntegerRejectsFloatString(): void + { + $this->expectException(TRestException::class); + $this->validator()->v(['n' => '1.5'], ['n' => 'integer']); + } + + public function testValidateIntegerRejectsScientificNotation(): void + { + $this->expectException(TRestException::class); + $this->validator()->v(['n' => '5e2'], ['n' => 'integer']); + } + + public function testValidateIntegerAcceptsNegativeAndZero(): void + { + $v = $this->validator(); + $this->assertSame(-7, $v->v(['n' => '-7'], ['n' => 'integer'])['n']); + $this->assertSame(0, $v->v(['n' => '0'], ['n' => 'integer'])['n']); + $this->assertSame(42, $v->v(['n' => 42], ['n' => 'integer'])['n']); + } + + /** @dataProvider booleanLikeValues */ + public function testValidateBooleanAcceptsEachLikeValue(mixed $value, bool $expected): void + { + $result = $this->validator()->v(['flag' => $value], ['flag' => 'boolean']); + $this->assertSame($expected, $result['flag']); + } + + public static function booleanLikeValues(): array + { + return [ + 'bool true' => [true, true], + 'bool false' => [false, false], + 'int 1' => [1, true], + 'int 0' => [0, false], + 'string "1"' => ['1', true], + 'string "0"' => ['0', false], + 'string "true"' => ['true', true], + 'string "false"' => ['false', false], + ]; + } + + public function testValidateBoolAliasMatchesBoolean(): void + { + $result = $this->validator()->v(['flag' => '1'], ['flag' => 'bool']); + $this->assertTrue($result['flag']); + } + + public function testValidateMaxOnNumberRejectsAboveLimit(): void + { + $this->expectException(TRestException::class); + $this->validator()->v(['n' => 200], ['n' => 'integer|max:150']); + } + + public function testValidateMaxOnNumberAcceptsBoundary(): void + { + $result = $this->validator()->v(['n' => 150], ['n' => 'integer|max:150']); + $this->assertSame(150, $result['n']); + } + + public function testValidateMinOnNumberAcceptsBoundary(): void + { + $result = $this->validator()->v(['n' => 5], ['n' => 'integer|min:5']); + $this->assertSame(5, $result['n']); + } + + public function testValidateMaxOnStringUsesMultibyteLength(): void + { + // mb_strlen counts characters, not bytes; the four-character string + // "héllo" (5 chars including é) should be rejected by max:4. + $this->expectException(TRestException::class); + $this->validator()->v(['s' => 'héllo'], ['s' => 'string|max:4']); + } + + public function testValidateMinOnStringUsesMultibyteLength(): void + { + // "héllo" is 5 mb chars; min:5 must pass. + $result = $this->validator()->v(['s' => 'héllo'], ['s' => 'string|min:5']); + $this->assertSame('héllo', $result['s']); + } + + public function testValidateInRuleTrimsWhitespace(): void + { + $result = $this->validator()->v( + ['status' => 'active'], + ['status' => 'in: active , inactive , pending '] + ); + $this->assertSame('active', $result['status']); + } + + public function testValidateEmailRejectsMissingTld(): void + { + $this->expectException(TRestException::class); + $this->validator()->v(['e' => 'user@'], ['e' => 'email']); + } + + public function testValidateUrlRejectsBareHostname(): void + { + $this->expectException(TRestException::class); + $this->validator()->v(['site' => 'example.com'], ['site' => 'url']); + } + + public function testValidateRequiredMissingField(): void + { + try { + $this->validator()->v([], ['name' => 'required|string']); + $this->fail('expected exception'); + } catch (TRestException $e) { + $this->assertArrayHasKey('name', $e->getErrors()); + } + } + + public function testValidateMultipleFieldErrorsCollected(): void + { + try { + $this->validator()->v( + ['email' => 'bad', 'age' => 'x'], + ['email' => 'email', 'age' => 'integer'] + ); + $this->fail('expected exception'); + } catch (TRestException $e) { + $errors = $e->getErrors(); + $this->assertArrayHasKey('email', $errors); + $this->assertArrayHasKey('age', $errors); + } + } + + // ── in: rule scalar guard (bug regression) ───────────────────────────────── + + public function testValidateInRuleRejectsArrayValueWithoutWarning(): void + { + // An array value must fail 'in' validation rather than emitting an + // "Array to string conversion" warning. + $this->expectException(TRestException::class); + $this->validator()->v(['role' => ['admin']], ['role' => 'in:admin,editor']); + } + + public function testValidateInRuleAcceptsValidScalar(): void + { + $out = $this->validator()->v(['role' => 'admin'], ['role' => 'in:admin,editor']); + $this->assertSame(['role' => 'admin'], $out); + } + + // ── validate() rule-as-array and presence/null branches ──────────────────── + + public function testValidateRuleSuppliedAsArray(): void + { + $out = $this->validator()->v(['n' => '5'], ['n' => ['required', 'integer']]); + $this->assertSame(['n' => 5], $out); + } + + public function testValidateRequiredPresentButNullFails(): void + { + $this->expectException(TRestException::class); + $this->validator()->v(['name' => null], ['name' => 'required|string']); + } + + public function testValidateNullableAbsentOmitsField(): void + { + $out = $this->validator()->v([], ['nickname' => 'nullable|string']); + $this->assertSame([], $out); + } + + public function testValidateNullablePresentNullKeepsNull(): void + { + $out = $this->validator()->v(['nickname' => null], ['nickname' => 'nullable|string']); + $this->assertArrayHasKey('nickname', $out); + $this->assertNull($out['nickname']); + } + + public function testValidateBooleanRuleCoercesValue(): void + { + $out = $this->validator()->v(['flag' => 'true'], ['flag' => 'boolean']); + $this->assertTrue($out['flag']); + $out2 = $this->validator()->v(['flag' => '0'], ['flag' => 'boolean']); + $this->assertFalse($out2['flag']); + } + + // ── getBody() edge cases ─────────────────────────────────────────────────── + + public function testGetBodyValidJsonScalarYieldsEmptyArray(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $r = $this->bodyResource('42'); + $this->assertSame([], $r->callGetBody()); + } + + public function testGetBodyJsonListIsReturned(): void + { + $_SERVER['REQUEST_METHOD'] = 'POST'; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $r = $this->bodyResource('[1,2,3]'); + $this->assertSame([1, 2, 3], $r->callGetBody()); + } + + public function testGetBodyGetVerbReturnsEmptyArray(): void + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + $r = $this->bodyResource('{"a":1}'); + $this->assertSame([], $r->callGetBody()); + } + + // ── input()/hasInput() null-value branch ─────────────────────────────────── + + public function testInputReturnsExplicitNullBodyValue(): void + { + $r = $this->inputResource(['opt' => null]); + // array_key_exists path: present-but-null returns null, not the default. + $this->assertNull($r->callInput('opt', 'fallback')); + } + + public function testHasInputTrueForNullBodyValue(): void + { + $r = $this->inputResource(['opt' => null]); + $this->assertTrue($r->callHasInput('opt')); + } + + // ── only()/except() edge cases ───────────────────────────────────────────── + + public function testOnlyAndExceptWithMissingAndEmptyKeys(): void + { + $r = $this->inputResource(['a' => 1, 'b' => 2]); + $this->assertSame(['a' => 1], $r->callOnly(['a', 'missing'])); + $this->assertSame([], $r->callOnly([])); + $this->assertSame(['a' => 1, 'b' => 2], $r->callExcept([])); + $this->assertSame(['b' => 2], $r->callExcept(['a'])); + } + + // ── response header injection guard (security) ───────────────────────────── + + public function testHeaderRejectsCrlfInValue(): void + { + $r = new class () extends TRestResource { + public function call(string $n, string $v): void + { + $this->header($n, $v); + } + }; + $this->expectException(\Prado\Exceptions\TInvalidDataValueException::class); + $r->call('X-Test', "ok\r\nX-Injected: evil"); + } + + public function testHeaderRejectsInvalidName(): void + { + $r = new class () extends TRestResource { + public function call(string $n, string $v): void + { + $this->header($n, $v); + } + }; + $this->expectException(\Prado\Exceptions\TInvalidDataValueException::class); + $r->call('Bad Name', 'value'); + } + + public function testHeaderReturnsSelfForFluency(): void + { + $r = new class () extends TRestResource { + public function call(): mixed + { + return $this->header('X-A', '1')->header('X-B', '2'); + } + public function headers(): array + { + return $this->getResponseHeaders(); + } + }; + $this->assertSame($r, $r->call()); + $this->assertSame(['X-A' => '1', 'X-B' => '2'], $r->headers()); + } +} diff --git a/tests/unit/Web/Services/TRestServiceTest.php b/tests/unit/Web/Services/TRestServiceTest.php new file mode 100644 index 000000000..35cc4984a --- /dev/null +++ b/tests/unit/Web/Services/TRestServiceTest.php @@ -0,0 +1,1343 @@ +compilePattern($pattern, $params); + } + + public function exposeMatchRoute(string $path): array + { + return $this->matchRoute($path); + } + + public function exposeResolveMethod(string $verb, bool $isItem): string + { + return $this->resolveMethod($verb, $isItem); + } + + public function exposeGetApiPath(string $pathInfo): string + { + // applyBasePath expects an already-ltrimmed path, mirroring what getApiPath() does. + return $this->applyBasePath(ltrim($pathInfo, '/')); + } + + /** Add a resource directly (bypass XML config). */ + public function addResourceDirect(string $pattern, string $class, array $params = [], array $props = []): void + { + $this->addResource($pattern, $class, $params, $props); + } + + public function exposeLoadResources(mixed $config): void + { + $this->loadResources($config); + } + + public function exposeXmlConfigToArray(\Prado\Xml\TXmlElement $config): array + { + return $this->xmlConfigToArray($config); + } + + public function exposeRegisterResource(array $item, string $prefix = ''): void + { + $this->registerResource($item, $prefix); + } + + public function exposeCreateResource(array $cfg): TRestResource + { + return $this->createResource($cfg); + } + + public function exposeDispatch(TRestResource $r, string $method, array $params): mixed + { + return $this->dispatchToResource($r, $method, $params); + } + + public function exposeIsEnabled(mixed $value): bool + { + return $this->isEnabled($value); + } + + public function exposeLoadConfigFile(string $file): array + { + return $this->loadConfigFile($file); + } + + public function getResources(): array + { + // matchRoute walks the table; we read it via reflection for assertions. + $ref = new ReflectionProperty(TRestService::class, '_resources'); + $ref->setAccessible(true); + return $ref->getValue($this); + } + + // ── Injection seam for run() lifecycle tests ────────────────────────────── + + private ?CapturingResponse $injectedResponse = null; + + public function setInjectedResponse(CapturingResponse $r): void + { + $this->injectedResponse = $r; + } + + public function getResponse() + { + return $this->injectedResponse ?? parent::getResponse(); + } +} + +/** + * THttpResponse subclass that captures status, headers, body, and content type + * in memory so run() can be asserted against without touching real HTTP output. + */ +class CapturingResponse extends \Prado\Web\THttpResponse +{ + public int $status = 200; + public array $headers = []; + public string $body = ''; + public string $contentType = ''; + public string $charset = ''; + + public function getStatusCode(): int { return $this->status; } + public function setStatusCode($status, $reason = null): void { $this->status = (int) $status; } + public function appendHeader($header, bool $replace = true, int $response_code = 0): void + { + $this->headers[] = $header; + } + public function write($str): void { $this->body .= $str; } + public function setContentType($value): void { $this->contentType = $value; } + public function setCharset($charset): void { $this->charset = $charset; } + + /** Convenience: find a header line matching the given Name. */ + public function headerLine(string $name): ?string + { + $needle = strtolower($name) . ':'; + foreach ($this->headers as $line) { + if (str_starts_with(strtolower($line), $needle)) { + return $line; + } + } + return null; + } +} + +// ── Resources used by dispatch / run tests ──────────────────────────────────── + +class DoStyleResource extends TRestResource +{ + public static array $log = []; + + public function doIndex(): array + { + self::$log[] = 'doIndex'; + return ['list' => true]; + } + + public function doShow(string $id): array + { + self::$log[] = "doShow:{$id}"; + return ['id' => $id]; + } + + public function doStore(): array + { + self::$log[] = 'doStore'; + return $this->created(['created' => true]); + } + + public function doDestroy(string $id): void + { + self::$log[] = "doDestroy:{$id}"; + $this->noContent(); + } +} + +class DefaultParamResource extends TRestResource +{ + public function doShow(string $id, string $extra = 'default-extra'): array + { + return ['id' => $id, 'extra' => $extra]; + } +} + +class MissingParamResource extends TRestResource +{ + public function doShow(string $missing): array + { + return ['missing' => $missing]; + } +} + +class NotAResource +{ + // Intentionally does not extend TRestResource — used to test createResource() guard. +} + +// ── Minimal concrete resource for dispatch tests ─────────────────────────────── + +class ServiceTestResource extends TRestResource +{ + public static array $log = []; + + public function index(): array + { + self::$log[] = 'index'; + return ['list' => true]; + } + + public function show(string $id): array + { + self::$log[] = "show:{$id}"; + return ['id' => $id]; + } + + public function store(): array + { + self::$log[] = 'store'; + return $this->created(['created' => true]); + } + + public function update(string $id): array + { + self::$log[] = "update:{$id}"; + return ['updated' => true]; + } + + public function patch(string $id): array + { + self::$log[] = "patch:{$id}"; + return ['patched' => true]; + } + + public function destroy(string $id): void + { + self::$log[] = "destroy:{$id}"; + $this->noContent(); + } +} + +// ── Nested-resource for multi-param injection test ──────────────────────────── + +class ServiceTestNestedResource extends TRestResource +{ + public static array $log = []; + + public function show(string $userId, string $id): array + { + self::$log[] = "show:{$userId}/{$id}"; + return ['userId' => $userId, 'id' => $id]; + } + + public function index(string $userId): array + { + self::$log[] = "index:{$userId}"; + return ['userId' => $userId]; + } +} + +// ── Test class ───────────────────────────────────────────────────────────────── + +/** + * Tests for TRestService. + */ +class TRestServiceTest extends PHPUnit\Framework\TestCase +{ + private TRestServiceExposed $service; + + /** Snapshot of $_SERVER taken before each test so mutations don't leak. */ + private array $serverBackup = []; + + protected function setUp(): void + { + ServiceTestResource::$log = []; + ServiceTestNestedResource::$log = []; + + // Full snapshot — restore any key we touch (PATH_INFO, REQUEST_METHOD, + // CONTENT_TYPE) AND any key we don't, in case a future test adds more. + $this->serverBackup = $_SERVER; + + $this->service = new TRestServiceExposed(); + $this->service->setBasePath('api/'); + $_SERVER['PATH_INFO'] = '/api/users'; + $_SERVER['REQUEST_METHOD'] = 'GET'; + } + + protected function tearDown(): void + { + $_SERVER = $this->serverBackup; + } + + // ── compilePattern ───────────────────────────────────────────────────────── + + public function testCompilePatternNoParams(): void + { + [$regex, $paramOrder, $isItem] = $this->service->exposeCompilePattern('users', []); + $this->assertMatchesRegularExpression($regex, 'users'); + $this->assertDoesNotMatchRegularExpression($regex, 'users/123'); + $this->assertSame([], $paramOrder); + $this->assertFalse($isItem); + } + + public function testCompilePatternOneParam(): void + { + [$regex, $paramOrder, $isItem] = $this->service->exposeCompilePattern('users/{id}', ['id' => '\d+']); + $this->assertMatchesRegularExpression($regex, 'users/42'); + $this->assertDoesNotMatchRegularExpression($regex, 'users/abc'); + $this->assertDoesNotMatchRegularExpression($regex, 'users/'); + $this->assertSame(['id'], $paramOrder); + $this->assertTrue($isItem); + } + + public function testCompilePatternTwoParams(): void + { + [$regex, $paramOrder, $isItem] = $this->service->exposeCompilePattern( + 'users/{userId}/posts/{id}', + ['userId' => '\d+', 'id' => '\d+'] + ); + $this->assertMatchesRegularExpression($regex, 'users/7/posts/99'); + $this->assertDoesNotMatchRegularExpression($regex, 'users/7/posts'); + $this->assertSame(['userId', 'id'], $paramOrder); + $this->assertTrue($isItem); + } + + public function testCompilePatternNestedCollectionIsNotItem(): void + { + [$regex, $paramOrder, $isItem] = $this->service->exposeCompilePattern( + 'users/{userId}/posts', + ['userId' => '\d+'] + ); + $this->assertMatchesRegularExpression($regex, 'users/5/posts'); + $this->assertSame(['userId'], $paramOrder); + $this->assertFalse($isItem); // last segment is 'posts', not {param} + } + + public function testCompilePatternDefaultConstraintMatchesNonSlash(): void + { + [$regex] = $this->service->exposeCompilePattern('items/{slug}', []); + $this->assertMatchesRegularExpression($regex, 'items/my-slug'); + $this->assertMatchesRegularExpression($regex, 'items/123'); + $this->assertDoesNotMatchRegularExpression($regex, 'items/a/b'); // no slash + } + + public function testCompilePatternEscapesLiteralDots(): void + { + [$regex] = $this->service->exposeCompilePattern('v1.0/users', []); + $this->assertMatchesRegularExpression($regex, 'v1.0/users'); + $this->assertDoesNotMatchRegularExpression($regex, 'v100/users'); // dot is literal + } + + // ── matchRoute ───────────────────────────────────────────────────────────── + + private function serviceWithRoutes(): TRestServiceExposed + { + $s = new TRestServiceExposed(); + $s->setBasePath('api/'); + $s->addResourceDirect('users', ServiceTestResource::class); + $s->addResourceDirect('users/{id}', ServiceTestResource::class, ['id' => '\d+']); + $s->addResourceDirect('users/{userId}/posts', ServiceTestNestedResource::class, ['userId' => '\d+']); + $s->addResourceDirect('users/{userId}/posts/{id}', ServiceTestNestedResource::class, ['userId' => '\d+', 'id' => '\d+']); + return $s; + } + + public function testMatchRouteCollectionRoute(): void + { + $s = $this->serviceWithRoutes(); + [$config, $params] = $s->exposeMatchRoute('users'); + $this->assertSame(ServiceTestResource::class, $config['class']); + $this->assertSame([], $params); + $this->assertFalse($config['isItem']); + } + + public function testMatchRouteItemRoute(): void + { + $s = $this->serviceWithRoutes(); + [$config, $params] = $s->exposeMatchRoute('users/42'); + $this->assertSame(ServiceTestResource::class, $config['class']); + $this->assertSame(['id' => '42'], $params); + $this->assertTrue($config['isItem']); + } + + public function testMatchRouteNestedCollection(): void + { + $s = $this->serviceWithRoutes(); + [$config, $params] = $s->exposeMatchRoute('users/7/posts'); + $this->assertSame(ServiceTestNestedResource::class, $config['class']); + $this->assertSame(['userId' => '7'], $params); + $this->assertFalse($config['isItem']); + } + + public function testMatchRouteNestedItem(): void + { + $s = $this->serviceWithRoutes(); + [$config, $params] = $s->exposeMatchRoute('users/7/posts/3'); + $this->assertSame(ServiceTestNestedResource::class, $config['class']); + $this->assertSame(['userId' => '7', 'id' => '3'], $params); + $this->assertTrue($config['isItem']); + } + + public function testMatchRouteThrows404WhenNoMatch(): void + { + $s = $this->serviceWithRoutes(); + $this->expectException(TRestException::class); + $this->expectExceptionCode(404); + $s->exposeMatchRoute('nonexistent/path'); + } + + public function testMatchRouteConstraintPreventsAlphaId(): void + { + $s = $this->serviceWithRoutes(); + // 'users/abc' does not match 'users/{id}' with id=\d+ + // but also does not match 'users' (needs exact match) + $this->expectException(TRestException::class); + $this->expectExceptionCode(404); + $s->exposeMatchRoute('users/abc'); + } + + // ── resolveMethod ────────────────────────────────────────────────────────── + + public function testResolveMethodGetCollection(): void + { + $this->assertSame('doIndex', $this->service->exposeResolveMethod('GET', false)); + } + + public function testResolveMethodHeadCollection(): void + { + $this->assertSame('doIndex', $this->service->exposeResolveMethod('HEAD', false)); + } + + public function testResolveMethodGetItem(): void + { + $this->assertSame('doShow', $this->service->exposeResolveMethod('GET', true)); + } + + public function testResolveMethodHeadItem(): void + { + $this->assertSame('doShow', $this->service->exposeResolveMethod('HEAD', true)); + } + + public function testResolveMethodPost(): void + { + $this->assertSame('doStore', $this->service->exposeResolveMethod('POST', false)); + $this->assertSame('doStore', $this->service->exposeResolveMethod('POST', true)); + } + + public function testResolveMethodPutItem(): void + { + $this->assertSame('doUpdate', $this->service->exposeResolveMethod('PUT', true)); + } + + public function testResolveMethodPatchItem(): void + { + $this->assertSame('doPatch', $this->service->exposeResolveMethod('PATCH', true)); + } + + public function testResolveMethodDelete(): void + { + $this->assertSame('doDestroy', $this->service->exposeResolveMethod('DELETE', false)); + $this->assertSame('doDestroy', $this->service->exposeResolveMethod('DELETE', true)); + } + + public function testResolveMethodUnknownVerbThrows405(): void + { + $this->expectException(TRestException::class); + $this->expectExceptionCode(405); + $this->service->exposeResolveMethod('TRACE', false); + } + + // ── getApiPath ───────────────────────────────────────────────────────────── + + public function testGetApiPathStripsBasePath(): void + { + $result = $this->service->exposeGetApiPath('/api/users/42'); + $this->assertSame('users/42', $result); + } + + public function testGetApiPathStripsLeadingSlash(): void + { + $result = $this->service->exposeGetApiPath('/api/posts'); + $this->assertSame('posts', $result); + } + + public function testGetApiPathWithEmptyBasePath(): void + { + $s = new TRestServiceExposed(); + $s->setBasePath(''); + $result = $s->exposeGetApiPath('/users/5'); + $this->assertSame('users/5', $result); + } + + public function testGetApiPathWhenBasePathNotPresentThrows404(): void + { + // PATH_INFO does not start with the base path — routes must not be + // reachable outside the configured prefix. + $this->expectException(TRestException::class); + $this->expectExceptionCode(404); + $this->service->exposeGetApiPath('/other/path'); + } + + public function testGetApiPathBareBasePathYieldsRootPath(): void + { + // '/api' (no trailing slash) addresses the service root, not a 404. + $this->assertSame('', $this->service->exposeGetApiPath('/api')); + } + + // ── Property accessors ───────────────────────────────────────────────────── + + public function testBasePathAccessor(): void + { + $s = new TRestService(); + $s->setBasePath('api/v2/'); + $this->assertSame('api/v2/', $s->getBasePath()); + } + + public function testEnableCorsAccessor(): void + { + $s = new TRestService(); + $this->assertFalse($s->getEnableCors()); + $s->setEnableCors(true); + $this->assertTrue($s->getEnableCors()); + $s->setEnableCors('false'); + $this->assertFalse($s->getEnableCors()); + } + + public function testAllowOriginAccessor(): void + { + $s = new TRestService(); + $this->assertSame('*', $s->getAllowOrigin()); + $s->setAllowOrigin('https://example.com'); + $this->assertSame('https://example.com', $s->getAllowOrigin()); + } + + public function testAllowMethodsAccessor(): void + { + $s = new TRestService(); + $s->setAllowMethods('GET, POST'); + $this->assertSame('GET, POST', $s->getAllowMethods()); + } + + public function testAllowHeadersAccessor(): void + { + $s = new TRestService(); + $s->setAllowHeaders('Authorization'); + $this->assertSame('Authorization', $s->getAllowHeaders()); + } + + public function testAllowCredentialsAccessor(): void + { + $s = new TRestService(); + $this->assertFalse($s->getAllowCredentials()); + $s->setAllowCredentials(true); + $this->assertTrue($s->getAllowCredentials()); + } + + public function testMaxAgeAccessor(): void + { + $s = new TRestService(); + $this->assertSame(86400, $s->getMaxAge()); + $s->setMaxAge(3600); + $this->assertSame(3600, $s->getMaxAge()); + } + + public function testExposeErrorsAccessor(): void + { + $s = new TRestService(); + $s->setExposeErrors(true); + $this->assertTrue($s->getExposeErrors()); + $s->setExposeErrors(false); + $this->assertFalse($s->getExposeErrors()); + } + + // ── compilePattern: constraint with alternation (regression for (?:…) wrap) ─ + + public function testCompilePatternAlternationConstraint(): void + { + [$regex] = $this->service->exposeCompilePattern( + 'items/{id}', + ['id' => '\d+|new'] + ); + $this->assertMatchesRegularExpression($regex, 'items/42'); + $this->assertMatchesRegularExpression($regex, 'items/new'); + $this->assertDoesNotMatchRegularExpression($regex, 'items/old'); + } + + // ── XML config parsing ──────────────────────────────────────────────────── + + private function xmlConfig(string $xml): \Prado\Xml\TXmlElement + { + $doc = new TXmlDocument('1.0', 'UTF-8'); + $doc->loadFromString($xml); + return $doc; + } + + public function testXmlConfigSimpleResource(): void + { + $cfg = $this->xmlConfig(''); + $arr = $this->service->exposeXmlConfigToArray($cfg); + $this->assertCount(1, $arr['resources']); + $this->assertSame('users', $arr['resources'][0]['pattern']); + $this->assertSame('DoStyleResource', $arr['resources'][0]['class']); + } + + public function testXmlConfigResourceWithParameters(): void + { + $cfg = $this->xmlConfig( + '' + ); + $arr = $this->service->exposeXmlConfigToArray($cfg); + $this->assertSame(['id' => '\d+'], $arr['resources'][0]['parameters']); + } + + public function testXmlConfigGroupCollectsInlineResources(): void + { + $xml = '' + . '' + . '' + . ''; + $arr = $this->service->exposeXmlConfigToArray($this->xmlConfig($xml)); + $this->assertCount(1, $arr['groups']); + $this->assertSame('v1/', $arr['groups'][0]['prefix']); + $this->assertCount(2, $arr['groups'][0]['resources']); + // Inline resources inside should NOT be in top-level resources. + $this->assertSame([], $arr['resources']); + } + + public function testLoadResourcesAppliesGroupPrefix(): void + { + $xml = '' + . '' + . ''; + $this->service->exposeLoadResources($this->xmlConfig($xml)); + $entries = $this->service->getResources(); + $this->assertCount(1, $entries); + $this->assertSame('v1/users', $entries[0]['pattern']); + } + + public function testLoadResourcesSkipsDisabledGroup(): void + { + $xml = '' + . '' + . ''; + $this->service->exposeLoadResources($this->xmlConfig($xml)); + $this->assertSame([], $this->service->getResources()); + } + + public function testLoadResourcesViaPhpArray(): void + { + $cfg = [ + 'resources' => [ + ['pattern' => 'a', 'class' => 'DoStyleResource'], + ], + 'groups' => [ + ['prefix' => 'v2/', 'resources' => [['pattern' => 'users', 'class' => 'DoStyleResource']]], + ], + ]; + // Force PHP config path by overriding configurationType — easier to just call + // the underlying registration directly via the public seam: + $this->service->exposeRegisterResource($cfg['resources'][0]); + foreach ($cfg['groups'][0]['resources'] as $r) { + $this->service->exposeRegisterResource($r, $cfg['groups'][0]['prefix']); + } + $entries = $this->service->getResources(); + $this->assertSame('a', $entries[0]['pattern']); + $this->assertSame('v2/users', $entries[1]['pattern']); + } + + public function testRegisterResourceThrowsWhenPatternMissing(): void + { + $this->expectException(TConfigurationException::class); + $this->service->exposeRegisterResource(['class' => 'DoStyleResource']); + } + + public function testRegisterResourceThrowsWhenClassMissing(): void + { + $this->expectException(TConfigurationException::class); + $this->service->exposeRegisterResource(['pattern' => 'x']); + } + + // ── Group file loading ──────────────────────────────────────────────────── + + private function withFixtureAlias(callable $fn): void + { + Prado::setPathOfAlias('RestFixtures', __DIR__ . '/fixtures'); + try { + $fn(); + } finally { + // no remove API; alias persists per process — harmless for tests + } + } + + public function testLoadResourcesFromPhpGroupFile(): void + { + $this->withFixtureAlias(function () { + $xml = ''; + $this->service->exposeLoadResources($this->xmlConfig($xml)); + $entries = $this->service->getResources(); + $patterns = array_column($entries, 'pattern'); + $this->assertContains('api/php-users', $patterns); + $this->assertContains('api/php-users/{id}', $patterns); + }); + } + + public function testLoadResourcesFromXmlGroupFile(): void + { + $this->withFixtureAlias(function () { + $xml = ''; + $this->service->exposeLoadResources($this->xmlConfig($xml)); + $entries = $this->service->getResources(); + $patterns = array_column($entries, 'pattern'); + $this->assertContains('api/xml-users', $patterns); + $this->assertContains('api/xml-users/{id}', $patterns); + }); + } + + public function testLoadResourcesGroupFileNotFoundThrows(): void + { + $xml = ''; + $this->expectException(TIOException::class); + $this->service->exposeLoadResources($this->xmlConfig($xml)); + } + + // ── createResource ──────────────────────────────────────────────────────── + + public function testCreateResourceInstantiatesClass(): void + { + $r = $this->service->exposeCreateResource([ + 'class' => DoStyleResource::class, + 'properties' => [], + ]); + $this->assertInstanceOf(DoStyleResource::class, $r); + } + + public function testCreateResourceRejectsNonResourceClass(): void + { + $this->expectException(TConfigurationException::class); + $this->service->exposeCreateResource([ + 'class' => NotAResource::class, + 'properties' => [], + ]); + } + + // ── dispatchToResource ───────────────────────────────────────────────────── + + public function testDispatchInjectsPathParamsByName(): void + { + $r = new DoStyleResource(); + $result = $this->service->exposeDispatch($r, 'doShow', ['id' => '42']); + $this->assertSame(['id' => '42'], $result); + } + + public function testDispatchUsesDefaultWhenParamMissing(): void + { + $r = new DefaultParamResource(); + $result = $this->service->exposeDispatch($r, 'doShow', ['id' => '7']); + $this->assertSame(['id' => '7', 'extra' => 'default-extra'], $result); + } + + public function testDispatchThrows500WhenRequiredParamMissing(): void + { + $r = new MissingParamResource(); + try { + $this->service->exposeDispatch($r, 'doShow', []); + $this->fail('expected exception'); + } catch (TRestException $e) { + $this->assertSame(500, $e->getStatusCode()); + $this->assertStringContainsString('missing', $e->getDetail()); + } + } + + public function testDispatchThrows405WhenMethodMissing(): void + { + // Use a subclass that overrides __call so the method really doesn't exist. + $r = new class () extends TRestResource {}; + try { + $this->service->exposeDispatch($r, 'doNotARealVerb', []); + $this->fail('expected exception'); + } catch (TRestException $e) { + $this->assertSame(405, $e->getStatusCode()); + } + } + + // ── isEnabled / Debug-aware group enable flag ────────────────────────────── + + public function testIsEnabledBooleanish(): void + { + $this->assertTrue($this->service->exposeIsEnabled(true)); + $this->assertTrue($this->service->exposeIsEnabled('true')); + $this->assertTrue($this->service->exposeIsEnabled('1')); + $this->assertFalse($this->service->exposeIsEnabled(false)); + $this->assertFalse($this->service->exposeIsEnabled('false')); + $this->assertFalse($this->service->exposeIsEnabled('0')); + } + + public function testIsEnabledDebugFollowsApplicationMode(): void + { + $app = Prado::getApplication(); + $originalMode = $app->getMode(); + try { + $app->setMode(\Prado\TApplicationMode::Debug); + $this->assertTrue($this->service->exposeIsEnabled('Debug')); + $this->assertTrue($this->service->exposeIsEnabled('debug')); // case-insensitive + $this->assertTrue($this->service->exposeIsEnabled('DEBUG')); + + $app->setMode(\Prado\TApplicationMode::Normal); + $this->assertFalse($this->service->exposeIsEnabled('Debug')); + + $app->setMode(\Prado\TApplicationMode::Performance); + $this->assertFalse($this->service->exposeIsEnabled('Debug')); + } finally { + $app->setMode($originalMode); + } + } + + public function testLoadResourcesGroupEnabledDebugRespectsMode(): void + { + $app = Prado::getApplication(); + $originalMode = $app->getMode(); + try { + $xml = '' + . '' + . ''; + + // Debug mode → group active + $app->setMode(\Prado\TApplicationMode::Debug); + $s = new TRestServiceExposed(); + $s->exposeLoadResources($this->xmlConfig($xml)); + $this->assertCount(1, $s->getResources()); + + // Performance mode → group skipped + $app->setMode(\Prado\TApplicationMode::Performance); + $s = new TRestServiceExposed(); + $s->exposeLoadResources($this->xmlConfig($xml)); + $this->assertCount(0, $s->getResources()); + } finally { + $app->setMode($originalMode); + } + } + + // ── Service-level configfile ─────────────────────────────────────────────── + + public function testXmlConfigCapturesConfigfileAttribute(): void + { + $cfg = $this->xmlConfig(''); + $arr = $this->service->exposeXmlConfigToArray($cfg); + $this->assertSame('App.config.rest', $arr['configfile']); + $this->assertCount(1, $arr['resources']); // inline entries still captured + } + + public function testLoadConfigFilePrefersPhpWhenBothExist(): void + { + $this->withFixtureAlias(function () { + $cfg = $this->service->exposeLoadConfigFile('RestFixtures.rest-config'); + $patterns = array_column($cfg['resources'], 'pattern'); + // .php fixture should win over the .xml fixture in the same directory + $this->assertContains('phpcfg-users', $patterns); + $this->assertNotContains('cfg-users', $patterns); + }); + } + + public function testLoadConfigFileFromXmlOnlyFixture(): void + { + $this->withFixtureAlias(function () { + $cfg = $this->service->exposeLoadConfigFile('RestFixtures.rest-xmlonly-config'); + $patterns = array_column($cfg['resources'], 'pattern'); + $this->assertContains('xmlcfg-users', $patterns); + $this->assertCount(1, $cfg['groups']); + $this->assertSame('v9/', $cfg['groups'][0]['prefix']); + }); + } + + public function testLoadResourcesUsesConfigfileAttribute(): void + { + $this->withFixtureAlias(function () { + $xml = '' + . '' + . ''; + $s = new TRestServiceExposed(); + $s->exposeLoadResources($this->xmlConfig($xml)); + $patterns = array_column($s->getResources(), 'pattern'); + + // From the .php config file (preferred over .xml): + $this->assertContains('phpcfg-users', $patterns); + $this->assertContains('v2/things', $patterns); + // Inline entry appended after external entries: + $this->assertContains('inline-extra', $patterns); + }); + } + + public function testLoadResourcesConfigfileWithDebugGroupRespectsMode(): void + { + // rest-config.xml has a . We can't + // reach that group via .php-preferred loading, so test via XML directly. + $app = Prado::getApplication(); + $originalMode = $app->getMode(); + try { + $this->withFixtureAlias(function () use ($app) { + $xmlFile = __DIR__ . '/fixtures/rest-config.xml'; + $doc = new TXmlDocument('1.0', 'UTF-8'); + $doc->loadFromFile($xmlFile); + + $app->setMode(\Prado\TApplicationMode::Debug); + $s = new TRestServiceExposed(); + $s->exposeLoadResources($doc); + $patterns = array_column($s->getResources(), 'pattern'); + $this->assertContains('debug/dump', $patterns); + + $app->setMode(\Prado\TApplicationMode::Normal); + $s = new TRestServiceExposed(); + $s->exposeLoadResources($doc); + $patterns = array_column($s->getResources(), 'pattern'); + $this->assertNotContains('debug/dump', $patterns); + }); + } finally { + $app->setMode($originalMode); + } + } + + public function testLoadResourcesConfigfileWithNestedGroupfile(): void + { + // rest-config.xml has . + // Verify that loading the configfile transitively pulls in the groupfile's resources. + $this->withFixtureAlias(function () { + $xmlFile = __DIR__ . '/fixtures/rest-config.xml'; + $doc = new TXmlDocument('1.0', 'UTF-8'); + $doc->loadFromFile($xmlFile); + + $s = new TRestServiceExposed(); + $s->exposeLoadResources($doc); + $patterns = array_column($s->getResources(), 'pattern'); + + // from configfile root: + $this->assertContains('cfg-users', $patterns); + $this->assertContains('v2/things', $patterns); + // from groupfile loaded by a group inside the configfile: + $this->assertContains('ext/php-users', $patterns); + $this->assertContains('ext/php-users/{id}', $patterns); + }); + } + + public function testLoadConfigFileNotFoundThrows(): void + { + $this->expectException(TIOException::class); + $this->service->exposeLoadConfigFile('NotAnAlias.nope'); + } + + // ── run() end-to-end lifecycle ──────────────────────────────────────────── + + /** + * THttpRequest caches PATH_INFO after init, so updating $_SERVER mid-test + * is not enough — we have to overwrite the cached private property. + */ + private function forcePathInfo(string $pathInfo): void + { + $request = Prado::getApplication()->getRequest(); + $ref = new ReflectionProperty(\Prado\Web\THttpRequest::class, '_pathInfo'); + $ref->setAccessible(true); + $ref->setValue($request, $pathInfo); + } + + private function runWith(string $verb, string $pathInfo, ?string $contentType = null): CapturingResponse + { + $response = new CapturingResponse(); + $this->service->setInjectedResponse($response); + $this->service->addResourceDirect('users', DoStyleResource::class); + $this->service->addResourceDirect('users/{id}', DoStyleResource::class); + + $_SERVER['REQUEST_METHOD'] = $verb; + if ($contentType !== null) { + $_SERVER['CONTENT_TYPE'] = $contentType; + } + $this->forcePathInfo($pathInfo); + + $this->service->run(); + return $response; + } + + public function testRunGetCollectionWritesJsonAndStatus200(): void + { + $r = $this->runWith('GET', '/api/users'); + $this->assertSame(200, $r->status); + $this->assertSame('application/json', $r->contentType); + $this->assertSame(['list' => true], json_decode($r->body, true)); + } + + public function testRunGetItemSendsIdParam(): void + { + $r = $this->runWith('GET', '/api/users/42'); + $this->assertSame(200, $r->status); + $this->assertSame(['id' => '42'], json_decode($r->body, true)); + } + + public function testRunPostSets201ViaCreatedHelper(): void + { + $r = $this->runWith('POST', '/api/users'); + $this->assertSame(201, $r->status); + $this->assertSame(['created' => true], json_decode($r->body, true)); + } + + public function testRunDeleteSets204WithEmptyBody(): void + { + $r = $this->runWith('DELETE', '/api/users/9'); + $this->assertSame(204, $r->status); + $this->assertSame('', $r->body); + } + + public function testRunHeadSuppressesBodyButStatusIs200(): void + { + $r = $this->runWith('HEAD', '/api/users'); + $this->assertSame(200, $r->status); + $this->assertSame('', $r->body); + } + + public function testRunUnknownVerbReturnsJson405Error(): void + { + $r = $this->runWith('TRACE', '/api/users'); + $this->assertSame(405, $r->status); + $body = json_decode($r->body, true); + $this->assertSame(405, $body['status']); + $this->assertSame('Method Not Allowed', $body['title']); + // DoStyleResource declares doIndex/doStore/doDestroy — on a collection + // route every standard verb maps to one of those. + $this->assertSame('Allow: GET, HEAD, POST, PUT, PATCH, DELETE', $r->headerLine('Allow')); + } + + public function testRunUnimplementedVerbReturns405WithAllowHeader(): void + { + // DoStyleResource has no doPatch — PATCH on an item route is rejected, + // and the Allow header lists what the resource does support. + $r = $this->runWith('PATCH', '/api/users/3'); + $this->assertSame(405, $r->status); + $this->assertSame('Allow: GET, HEAD, POST, DELETE', $r->headerLine('Allow')); + } + + public function testRunPathNotMatchedYields404Json(): void + { + $r = $this->runWith('GET', '/api/nope'); + $this->assertSame(404, $r->status); + $body = json_decode($r->body, true); + $this->assertSame(404, $body['status']); + $this->assertSame('Not Found', $body['title']); + } + + public function testRunOptionsPreflightReturns204WhenCorsEnabled(): void + { + $this->service->setEnableCors(true); + $r = $this->runWith('OPTIONS', '/api/users'); + $this->assertSame(204, $r->status); + $this->assertNotNull($r->headerLine('Access-Control-Allow-Origin')); + $this->assertNotNull($r->headerLine('Access-Control-Allow-Methods')); + } + + public function testRunCorsEmitsAllowOriginOnRegularRequest(): void + { + $this->service->setEnableCors(true); + $this->service->setAllowOrigin('https://example.com'); + $r = $this->runWith('GET', '/api/users'); + $this->assertSame('Access-Control-Allow-Origin: https://example.com', $r->headerLine('Access-Control-Allow-Origin')); + $this->assertSame('Vary: Origin', $r->headerLine('Vary')); + } + + public function testRunCorsCredentialsWithExplicitOriginEmitsHeaders(): void + { + $this->service->setEnableCors(true); + $this->service->setAllowCredentials(true); + $this->service->setAllowOrigin('https://app.example.org'); + $r = $this->runWith('GET', '/api/users'); + $this->assertSame('Access-Control-Allow-Origin: https://app.example.org', $r->headerLine('Access-Control-Allow-Origin')); + $this->assertSame('Access-Control-Allow-Credentials: true', $r->headerLine('Access-Control-Allow-Credentials')); + $this->assertSame('Vary: Origin', $r->headerLine('Vary')); + } + + public function testInitRejectsCorsCredentialsWithWildcardOrigin(): void + { + $s = new TRestServiceExposed(); + $s->setEnableCors(true); + $s->setAllowCredentials(true); + $this->expectException(TConfigurationException::class); + $s->init(null); + } + + public function testRunCorsCredentialsWithWildcardOriginYields500(): void + { + // sendCorsHeaders() re-validates so programmatic misconfiguration after + // init() surfaces as a 500 instead of reflecting arbitrary origins. + $this->service->setEnableCors(true); + $this->service->setAllowCredentials(true); + $r = $this->runWith('GET', '/api/users'); + $this->assertSame(500, $r->status); + } + + public function testRunRegularRequestOmitsPreflightOnlyHeaders(): void + { + $this->service->setEnableCors(true); + $r = $this->runWith('GET', '/api/users'); + $this->assertNotNull($r->headerLine('Access-Control-Allow-Origin')); + $this->assertNull($r->headerLine('Access-Control-Allow-Methods')); + $this->assertNull($r->headerLine('Access-Control-Allow-Headers')); + $this->assertNull($r->headerLine('Access-Control-Max-Age')); + } + + public function testRunCorsWildcardSkipsVaryHeader(): void + { + $this->service->setEnableCors(true); + $this->service->setAllowOrigin('*'); + $r = $this->runWith('GET', '/api/users'); + $this->assertSame('Access-Control-Allow-Origin: *', $r->headerLine('Access-Control-Allow-Origin')); + $this->assertNull($r->headerLine('Vary')); + } + + public function testRunUncaughtExceptionProducesJson500(): void + { + // Resource whose doIndex throws a plain Exception. + $throwing = new class () extends TRestResource { + public function doIndex(): array { throw new \RuntimeException('boom'); } + }; + $this->service->addResourceDirect('throw', $throwing::class); + // Re-register the throwing class as a NAMED class is impossible for + // anonymous; use a different fixture instead. + $service = new TRestServiceExposed(); + $response = new CapturingResponse(); + $service->setInjectedResponse($response); + $service->setBasePath('api/'); + $service->addResourceDirect('boom', ThrowingResource::class); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $this->forcePathInfo('/api/boom'); + $service->run(); + + $this->assertSame(500, $response->status); + $body = json_decode($response->body, true); + $this->assertSame(500, $body['status']); + $this->assertSame('Internal Server Error', $body['title']); + } + + public function testRunExposeErrorsTrueIncludesExceptionMessage(): void + { + $service = new TRestServiceExposed(); + $service->setBasePath('api/'); + $service->setExposeErrors(true); + $response = new CapturingResponse(); + $service->setInjectedResponse($response); + $service->addResourceDirect('boom', ThrowingResource::class); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $this->forcePathInfo('/api/boom'); + $service->run(); + + $body = json_decode($response->body, true); + $this->assertSame('boom!', $body['detail']); + } + + public function testRunExposeErrorsFalseHidesExceptionMessage(): void + { + $service = new TRestServiceExposed(); + $service->setBasePath('api/'); + $service->setExposeErrors(false); + $response = new CapturingResponse(); + $service->setInjectedResponse($response); + $service->addResourceDirect('boom', ThrowingResource::class); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $this->forcePathInfo('/api/boom'); + $service->run(); + + $body = json_decode($response->body, true); + $this->assertArrayNotHasKey('detail', $body); + } + + public function testRunTRestExceptionThrownByResourceIsSerialized(): void + { + $service = new TRestServiceExposed(); + $service->setBasePath('api/'); + $response = new CapturingResponse(); + $service->setInjectedResponse($response); + $service->addResourceDirect('missing/{id}', NotFoundingResource::class); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $this->forcePathInfo('/api/missing/42'); + $service->run(); + + $this->assertSame(404, $response->status); + $body = json_decode($response->body, true); + $this->assertSame('User 42 not found.', $body['detail']); + } + + // ── 422 / 429 end-to-end (HIGH regression) ───────────────────────────────── + + public function testRunUnprocessableEntityEmits422JsonEnvelope(): void + { + // Regression: THttpResponse must know 422 so sendErrorResponse() does not + // raise a secondary exception; the validation envelope must reach the client. + $service = new TRestServiceExposed(); + $service->setBasePath('api/'); + $response = new CapturingResponse(); + $service->setInjectedResponse($response); + $service->addResourceDirect('signup', ValidatingResource::class); + $_SERVER['REQUEST_METHOD'] = 'POST'; + $this->forcePathInfo('/api/signup'); + $service->run(); + + $this->assertSame(422, $response->status); + $body = json_decode($response->body, true); + $this->assertSame(422, $body['status']); + $this->assertSame('Unprocessable Entity', $body['title']); + $this->assertArrayHasKey('errors', $body); + $this->assertSame(['email' => ['required']], $body['errors']); + } + + public function testRunTooManyRequestsEmits429(): void + { + $service = new TRestServiceExposed(); + $service->setBasePath('api/'); + $response = new CapturingResponse(); + $service->setInjectedResponse($response); + $service->addResourceDirect('limited', RateLimitedResource::class); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $this->forcePathInfo('/api/limited'); + $service->run(); + + $this->assertSame(429, $response->status); + $this->assertSame(429, json_decode($response->body, true)['status']); + } + + // ── compilePattern parameter-name validation ─────────────────────────────── + + public function testCompilePatternRejectsInvalidParamName(): void + { + $this->expectException(TConfigurationException::class); + $this->service->exposeCompilePattern('users/{1bad}', []); + } + + public function testCompilePatternRejectsDuplicateParamName(): void + { + $this->expectException(TConfigurationException::class); + $this->service->exposeCompilePattern('a/{id}/b/{id}', []); + } + + // ── applyBasePath segment boundary ───────────────────────────────────────── + + public function testGetApiPathRejectsPartialSegmentPrefix(): void + { + // BasePath "api" must not capture "apidocs/x"; it lies outside the base. + $s = new TRestServiceExposed(); + $s->setBasePath('api'); + $this->expectException(TRestException::class); + $this->expectExceptionCode(404); + $s->exposeGetApiPath('/apidocs/x'); + } + + public function testGetApiPathStripsExactSegment(): void + { + $s = new TRestServiceExposed(); + $s->setBasePath('api'); + $this->assertSame('users/1', $s->exposeGetApiPath('/api/users/1')); + $this->assertSame('', $s->exposeGetApiPath('/api')); + } + + // ── enabled on individual resource ───────────────────────────────────────── + + public function testRegisterResourceSkipsDisabledResource(): void + { + $this->service->exposeRegisterResource(['pattern' => 'x', 'class' => DoStyleResource::class, 'enabled' => 'false']); + $this->assertSame([], $this->service->getResources()); + } + + public function testRegisterResourceKeepsEnabledResource(): void + { + $this->service->exposeRegisterResource(['pattern' => 'x', 'class' => DoStyleResource::class, 'enabled' => 'true']); + $this->assertCount(1, $this->service->getResources()); + } + + // ── loadResources early returns ──────────────────────────────────────────── + + public function testLoadResourcesNullConfigIsNoOp(): void + { + $this->service->exposeLoadResources(null); + $this->assertSame([], $this->service->getResources()); + } + + public function testLoadResourcesNonArrayNonXmlConfigIsNoOp(): void + { + $this->service->exposeLoadResources('a string'); + $this->assertSame([], $this->service->getResources()); + } + + // ── isEnabled edge cases ─────────────────────────────────────────────────── + + public function testIsEnabledDebugIgnoresSurroundingWhitespaceIsLiteral(): void + { + // ' Debug ' is not the literal 'Debug', so it falls through to boolean parsing. + $this->assertFalse($this->service->exposeIsEnabled(' Debug ')); + } + + public function testIsEnabledEmptyStringIsFalse(): void + { + $this->assertFalse($this->service->exposeIsEnabled('')); + } + + // ── 405 Allow header ─────────────────────────────────────────────────────── + + public function testRun405EmitsAllowHeader(): void + { + // DoStyleResource implements doIndex/doShow/doStore/doDestroy but not doUpdate. + $r = $this->runWith('PUT', '/api/users/5'); + $this->assertSame(405, $r->status); + $this->assertNotNull($r->headerLine('Allow')); + } + + // ── CORS preflight emits the documented values ───────────────────────────── + + public function testCorsPreflightEmitsMethodsHeadersAndMaxAge(): void + { + $this->service->setEnableCors(true); + $this->service->setAllowMethods('GET, POST'); + $this->service->setAllowHeaders('Authorization'); + $this->service->setMaxAge(120); + $r = $this->runWith('OPTIONS', '/api/users'); + $this->assertSame(204, $r->status); + $this->assertSame('Access-Control-Allow-Methods: GET, POST', $r->headerLine('Access-Control-Allow-Methods')); + $this->assertSame('Access-Control-Allow-Headers: Authorization', $r->headerLine('Access-Control-Allow-Headers')); + $this->assertSame('Access-Control-Max-Age: 120', $r->headerLine('Access-Control-Max-Age')); + } +} + +/** Resource whose doIndex throws a generic exception — used for 500 tests. */ +class ThrowingResource extends TRestResource +{ + public function doIndex(): array + { + throw new \RuntimeException('boom!'); + } +} + +/** Resource whose doShow throws a TRestException 404 with detail. */ +class NotFoundingResource extends TRestResource +{ + public function doShow(string $id): array + { + $this->notFound("User {$id} not found."); + } +} + +/** Resource whose doStore raises a 422 validation fault. */ +class ValidatingResource extends TRestResource +{ + public function doStore(): array + { + $this->unprocessable(['email' => ['required']]); + } +} + +/** Resource whose doIndex raises a 429. */ +class RateLimitedResource extends TRestResource +{ + public function doIndex(): array + { + $this->abort(429, 'Slow down.'); + } +} diff --git a/tests/unit/Web/Services/fixtures/rest-config.php b/tests/unit/Web/Services/fixtures/rest-config.php new file mode 100644 index 000000000..3d9b24bb0 --- /dev/null +++ b/tests/unit/Web/Services/fixtures/rest-config.php @@ -0,0 +1,15 @@ + [ + ['pattern' => 'phpcfg-users', 'class' => 'DoStyleResource'], + ], + 'groups' => [ + [ + 'prefix' => 'v2/', + 'resources' => [ + ['pattern' => 'things', 'class' => 'DoStyleResource'], + ], + ], + ], +]; diff --git a/tests/unit/Web/Services/fixtures/rest-config.xml b/tests/unit/Web/Services/fixtures/rest-config.xml new file mode 100644 index 000000000..5b89aa4fc --- /dev/null +++ b/tests/unit/Web/Services/fixtures/rest-config.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tests/unit/Web/Services/fixtures/rest-php.php b/tests/unit/Web/Services/fixtures/rest-php.php new file mode 100644 index 000000000..e0a67c436 --- /dev/null +++ b/tests/unit/Web/Services/fixtures/rest-php.php @@ -0,0 +1,8 @@ + [ + ['pattern' => 'php-users', 'class' => DoStyleResource::class], + ['pattern' => 'php-users/{id}', 'class' => DoStyleResource::class, 'parameters' => ['id' => '\d+']], + ], +]; diff --git a/tests/unit/Web/Services/fixtures/rest-xml.xml b/tests/unit/Web/Services/fixtures/rest-xml.xml new file mode 100644 index 000000000..42d573f4c --- /dev/null +++ b/tests/unit/Web/Services/fixtures/rest-xml.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/unit/Web/Services/fixtures/rest-xmlonly-config.xml b/tests/unit/Web/Services/fixtures/rest-xmlonly-config.xml new file mode 100644 index 000000000..b062d63cb --- /dev/null +++ b/tests/unit/Web/Services/fixtures/rest-xmlonly-config.xml @@ -0,0 +1,7 @@ + + + + + + +