diff --git a/.gitattributes b/.gitattributes index c8456b6..2c451e9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,11 @@ +* text=auto eol=lf +*.php text eol=lf +*.md text eol=lf +*.json text eol=lf +*.xml text eol=lf +*.yml text eol=lf +*.yaml text eol=lf + #perform LF normalization * text eol=crlf *.php text eol=crlf diff --git a/README.md b/README.md index 93a5c3f..64d6899 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ With well-established PHP HTTP libraries available, you might wonder why this on - **Authentication Support**: Built-in support for various authentication schemes (Basic, Bearer, etc.) - **HTTP Method Support**: Support for all standard HTTP methods (GET, POST, PUT, DELETE, etc.) - **Content Type Handling**: Support for `application/json`, `application/x-www-form-urlencoded`, and `multipart/form-data` +- **Per-Method Content Type Control**: `#[Consumes]` annotation to accept custom content types (e.g. `application/octet-stream`, `application/xml`) on specific methods - **Object Mapping**: Automatic mapping of request parameters to PHP objects - **Comprehensive Testing**: Built-in testing utilities with `ServiceTestCase` class - **Error Handling**: Structured error responses with appropriate HTTP status codes diff --git a/WebFiori/Http/APIFilter.php b/WebFiori/Http/APIFilter.php index 81e3e77..21b5043 100644 --- a/WebFiori/Http/APIFilter.php +++ b/WebFiori/Http/APIFilter.php @@ -346,6 +346,7 @@ private static function applyBasicFilterOnly($def,$toBeFiltered) { if (gettype($toBeFiltered) == 'array') { return $toBeFiltered; } + if (gettype($toBeFiltered) == 'boolean') { return $toBeFiltered; } @@ -450,6 +451,29 @@ private function applyJsonBasicFilter(Json $extraClean, $toBeFiltered, $def) { $extraClean->addNull($name); } } + /** + * Checks allowed values and pattern constraints on a filtered value. + * + * @param mixed $value The filtered value. + * @param RequestParameter $param The parameter definition. + * + * @return mixed The value if valid, or self::INVALID if constraints fail. + */ + private static function checkAllowedAndPattern($value, RequestParameter $param) { + $allowed = $param->getAllowedValues(); + + if (!empty($allowed) && !in_array($value, $allowed, true)) { + return self::INVALID; + } + + $pattern = $param->getPattern(); + + if ($pattern !== null && is_string($value) && !preg_match($pattern, $value)) { + return self::INVALID; + } + + return $value; + } private function checkExtracted(Json $extraClean, $name, $defaultVal) { $extractedVal = $extraClean->get($name); @@ -555,6 +579,7 @@ private function cleanJsonStr($extraClean, $def, $toBeFiltered) { if (strlen($cleaned) == 0 && $def['options']['options']['allow-empty'] === false) { $extraClean->add($name, null); + return; } @@ -980,27 +1005,4 @@ private function setInputStreamHelper($trimmed, $mode) : bool { return false; } - /** - * Checks allowed values and pattern constraints on a filtered value. - * - * @param mixed $value The filtered value. - * @param RequestParameter $param The parameter definition. - * - * @return mixed The value if valid, or self::INVALID if constraints fail. - */ - private static function checkAllowedAndPattern($value, RequestParameter $param) { - $allowed = $param->getAllowedValues(); - - if (!empty($allowed) && !in_array($value, $allowed, true)) { - return self::INVALID; - } - - $pattern = $param->getPattern(); - - if ($pattern !== null && is_string($value) && !preg_match($pattern, $value)) { - return self::INVALID; - } - - return $value; - } } diff --git a/WebFiori/Http/Annotations/Consumes.php b/WebFiori/Http/Annotations/Consumes.php new file mode 100644 index 0000000..60fb327 --- /dev/null +++ b/WebFiori/Http/Annotations/Consumes.php @@ -0,0 +1,40 @@ +contentTypes = $contentTypes; + } +} diff --git a/WebFiori/Http/ErrorResponse.php b/WebFiori/Http/ErrorResponse.php index 474fbde..a6011b2 100644 --- a/WebFiori/Http/ErrorResponse.php +++ b/WebFiori/Http/ErrorResponse.php @@ -124,6 +124,25 @@ public static function missingServiceName() : array { return ['json' => $json, 'code' => 404]; } + /** + * Generates a 406 Not Acceptable response. + * + * @param array $supported The content types the server can produce. + * + * @return array{json: Json, code: int} The response body and HTTP code. + */ + public static function notAcceptable(array $supported = []) : array { + $json = new Json(); + $json->add('message', 'Not Acceptable'); + $json->add('type', WebService::E); + $json->add('http-code', 406); + + if (!empty($supported)) { + $json->add('more-info', new Json(['supported' => $supported])); + } + + return ['json' => $json, 'code' => 406]; + } /** * Generates a 404 response for unsupported service. * @@ -166,25 +185,6 @@ public static function unauthorized(?string $message = null) : array { return ['json' => $json, 'code' => 401]; } - /** - * Generates a 406 Not Acceptable response. - * - * @param array $supported The content types the server can produce. - * - * @return array{json: Json, code: int} The response body and HTTP code. - */ - public static function notAcceptable(array $supported = []) : array { - $json = new Json(); - $json->add('message', 'Not Acceptable'); - $json->add('type', WebService::E); - $json->add('http-code', 406); - - if (!empty($supported)) { - $json->add('more-info', new Json(['supported' => $supported])); - } - - return ['json' => $json, 'code' => 406]; - } /** * Formats an array of parameter names into a comma-separated quoted string. */ diff --git a/WebFiori/Http/OpenAPI/OpenAPIGenerator.php b/WebFiori/Http/OpenAPI/OpenAPIGenerator.php index 1dcfef2..16b732f 100644 --- a/WebFiori/Http/OpenAPI/OpenAPIGenerator.php +++ b/WebFiori/Http/OpenAPI/OpenAPIGenerator.php @@ -22,6 +22,44 @@ * @author Ibrahim */ class OpenAPIGenerator { + /** + * Discovers WebService instances in a given namespace. + * + * Scans all declared classes for those belonging to the namespace, + * extending WebService, having #[RestController], and not being abstract. + * + * @param string $namespace The namespace to scan. + * + * @return WebService[] Array of instantiated service objects. + */ + public static function discoverServices(string $namespace) : array { + $namespace = rtrim($namespace, '\\').'\\'; + $services = []; + + foreach (get_declared_classes() as $class) { + if (!str_starts_with($class, $namespace)) { + continue; + } + + if (!is_subclass_of($class, WebService::class)) { + continue; + } + + $reflection = new \ReflectionClass($class); + + if ($reflection->isAbstract()) { + continue; + } + + if (empty($reflection->getAttributes(RestController::class))) { + continue; + } + + $services[] = new $class(); + } + + return $services; + } /** * Generates an OpenAPI specification from an array of web services. * @@ -68,43 +106,4 @@ public function generateFromNamespace(string $namespace, string $description = ' return $this->generate($services, $description, $version, $basePath); } - - /** - * Discovers WebService instances in a given namespace. - * - * Scans all declared classes for those belonging to the namespace, - * extending WebService, having #[RestController], and not being abstract. - * - * @param string $namespace The namespace to scan. - * - * @return WebService[] Array of instantiated service objects. - */ - public static function discoverServices(string $namespace) : array { - $namespace = rtrim($namespace, '\\') . '\\'; - $services = []; - - foreach (get_declared_classes() as $class) { - if (!str_starts_with($class, $namespace)) { - continue; - } - - if (!is_subclass_of($class, WebService::class)) { - continue; - } - - $reflection = new \ReflectionClass($class); - - if ($reflection->isAbstract()) { - continue; - } - - if (empty($reflection->getAttributes(RestController::class))) { - continue; - } - - $services[] = new $class(); - } - - return $services; - } } diff --git a/WebFiori/Http/OpenAPI/OpenAPIObject.php b/WebFiori/Http/OpenAPI/OpenAPIObject.php index de4f3bc..f885a6d 100644 --- a/WebFiori/Http/OpenAPI/OpenAPIObject.php +++ b/WebFiori/Http/OpenAPI/OpenAPIObject.php @@ -29,6 +29,7 @@ public function getDescription() : ?string { public function setDescription(string $description) : static { $this->description = $description; + return $this; } /** diff --git a/WebFiori/Http/OpenAPI/OpenAPISpecService.php b/WebFiori/Http/OpenAPI/OpenAPISpecService.php index 6139ed6..57dd746 100644 --- a/WebFiori/Http/OpenAPI/OpenAPISpecService.php +++ b/WebFiori/Http/OpenAPI/OpenAPISpecService.php @@ -34,10 +34,10 @@ */ #[RestController(name: 'openapi', description: 'OpenAPI specification endpoint')] class OpenAPISpecService extends WebService { - private string $namespace; private string $apiBasePath; private string $apiTitle; private string $apiVersion; + private string $namespace; /** * Creates a new OpenAPI spec service. @@ -55,10 +55,6 @@ public function __construct(string $namespace, string $basePath = '', string $ti $this->apiVersion = $version; } - public function isAuthorized(): bool { - return true; - } - #[GetMapping] #[ResponseBody] #[AllowAnonymous] @@ -73,6 +69,10 @@ public function getSpec(): JsonI { ); } + public function isAuthorized(): bool { + return true; + } + public function processRequest() { } } diff --git a/WebFiori/Http/OpenAPI/OperationObj.php b/WebFiori/Http/OpenAPI/OperationObj.php index 96d139a..825d1c6 100644 --- a/WebFiori/Http/OpenAPI/OperationObj.php +++ b/WebFiori/Http/OpenAPI/OperationObj.php @@ -24,15 +24,6 @@ * @see https://spec.openapis.org/oas/v3.1.0#operation-object */ class OperationObj implements JsonI { - /** - * The list of possible responses as they are returned from executing this operation. - * - * REQUIRED. - * - * @var ResponsesObj - */ - private ResponsesObj $responses; - /** * A list of parameters that are applicable for this operation. * @@ -46,21 +37,19 @@ class OperationObj implements JsonI { * @var Json|null */ private ?Json $requestBody = null; + /** + * The list of possible responses as they are returned from executing this operation. + * + * REQUIRED. + * + * @var ResponsesObj + */ + private ResponsesObj $responses; public function __construct(ResponsesObj $responses) { $this->responses = $responses; } - public function getResponses(): ResponsesObj { - return $this->responses; - } - - public function setResponses(ResponsesObj $responses): OperationObj { - $this->responses = $responses; - - return $this; - } - /** * Adds a parameter to this operation. * @@ -83,6 +72,19 @@ public function getParameters(): array { return $this->parameters; } + /** + * Returns the request body. + * + * @return Json|null + */ + public function getRequestBody(): ?Json { + return $this->requestBody; + } + + public function getResponses(): ResponsesObj { + return $this->responses; + } + /** * Sets the request body for this operation. * @@ -96,13 +98,10 @@ public function setRequestBody(Json $requestBody): OperationObj { return $this; } - /** - * Returns the request body. - * - * @return Json|null - */ - public function getRequestBody(): ?Json { - return $this->requestBody; + public function setResponses(ResponsesObj $responses): OperationObj { + $this->responses = $responses; + + return $this; } public function toJSON(): Json { diff --git a/WebFiori/Http/ParamOption.php b/WebFiori/Http/ParamOption.php index 3416f4b..d7fa957 100644 --- a/WebFiori/Http/ParamOption.php +++ b/WebFiori/Http/ParamOption.php @@ -16,6 +16,10 @@ * @author Ibrahim */ class ParamOption { + /** + * An option which is used to restrict parameter value to a set of allowed values. + */ + const ALLOWED_VALUES = 'allowed-values'; /** * An option which is used to set default value if parameter is optional and * not provided. @@ -42,6 +46,10 @@ class ParamOption { * An option which is used to set minimum allowed length. Applicable to string types only. */ const MAX_LENGTH = 'max-length'; + /** + * An option which is used to set a custom validation error message for the parameter. + */ + const MESSAGE = 'message'; /** * An option which is used to set the methods at which the parameter must exist. */ @@ -63,20 +71,12 @@ class ParamOption { * An option which is used to indicate that a parameter is optional or not (bool). Applies to all data types. */ const OPTIONAL = 'optional'; - /** - * Parameter type option. Applies to all data types. - */ - const TYPE = 'type'; - /** - * An option which is used to restrict parameter value to a set of allowed values. - */ - const ALLOWED_VALUES = 'allowed-values'; /** * An option which is used to set a regex pattern for string validation. */ const PATTERN = 'pattern'; /** - * An option which is used to set a custom validation error message for the parameter. + * Parameter type option. Applies to all data types. */ - const MESSAGE = 'message'; + const TYPE = 'type'; } diff --git a/WebFiori/Http/RequestParameter.php b/WebFiori/Http/RequestParameter.php index 50c490e..059337f 100644 --- a/WebFiori/Http/RequestParameter.php +++ b/WebFiori/Http/RequestParameter.php @@ -31,6 +31,12 @@ class RequestParameter implements JsonI { * @var array */ public const RESERVED_NAMES = ['action', 'service', 'service-name']; + /** + * An array of allowed values for the parameter. + * + * @var array + */ + private $allowedValues; /** A boolean value that is set to true in case the @@ -89,6 +95,12 @@ class RequestParameter implements JsonI { * */ private $maxVal; + /** + * Custom validation error message. + * + * @var string|null + */ + private $message; /** * An array of request methods at which the parameter must exist. * @@ -117,19 +129,6 @@ class RequestParameter implements JsonI { * */ private $name; - /** - * The type of the data the parameter will represent. - * - * @var string - * - */ - private $type; - /** - * An array of allowed values for the parameter. - * - * @var array - */ - private $allowedValues; /** * A regex pattern for validating string parameters. * @@ -137,11 +136,12 @@ class RequestParameter implements JsonI { */ private $pattern; /** - * Custom validation error message. + * The type of the data the parameter will represent. + * + * @var string * - * @var string|null */ - private $message; + private $type; /** * Creates new instance of the class. * @@ -307,6 +307,14 @@ public static function create(array $options) : ?RequestParameter { return null; } + /** + * Returns the array of allowed values for the parameter. + * + * @return array The allowed values. Empty array means no restriction. + */ + public function getAllowedValues() : array { + return $this->allowedValues; + } /** * Returns the function that is used as a custom filter * for the parameter. @@ -366,6 +374,14 @@ public function getMaxLength() { public function getMaxValue() { return $this->maxVal; } + /** + * Returns the custom validation error message. + * + * @return string|null The message or null if not set. + */ + public function getMessage() : ?string { + return $this->message; + } /** * Returns an array of request methods at which the parameter must exist. * @@ -409,31 +425,6 @@ public function getMinValue() { public function getName() : string { return $this->name; } - /** - * Returns the type of the parameter. - * - * @return string The type of the parameter (Such as 'string', 'email', 'integer'). - * - */ - public function getType() : string { - return $this->type; - } - /** - * Returns the array of allowed values for the parameter. - * - * @return array The allowed values. Empty array means no restriction. - */ - public function getAllowedValues() : array { - return $this->allowedValues; - } - /** - * Sets the allowed values for the parameter. - * - * @param array $values An array of permitted values. - */ - public function setAllowedValues(array $values) : void { - $this->allowedValues = $values; - } /** * Returns the regex pattern used for validation. * @@ -443,34 +434,13 @@ public function getPattern() : ?string { return $this->pattern; } /** - * Sets a regex pattern for validating the parameter value. - * - * @param string $regex A valid PHP regex (e.g. '/^[a-z]+$/'). - * - * @return bool True if the regex is valid and was set, false otherwise. - */ - public function setPattern(string $regex) : bool { - if (@preg_match($regex, '') !== false) { - $this->pattern = $regex; - return true; - } - return false; - } - /** - * Returns the custom validation error message. + * Returns the type of the parameter. * - * @return string|null The message or null if not set. - */ - public function getMessage() : ?string { - return $this->message; - } - /** - * Sets a custom validation error message for this parameter. + * @return string The type of the parameter (Such as 'string', 'email', 'integer'). * - * @param string $message The error message to display when validation fails. */ - public function setMessage(string $message) : void { - $this->message = $message; + public function getType() : string { + return $this->type; } /** * Checks if we need to apply basic filter or not @@ -509,6 +479,14 @@ public function isEmptyStringAllowed() : bool { public function isOptional() : bool { return $this->isOptional; } + /** + * Sets the allowed values for the parameter. + * + * @param array $values An array of permitted values. + */ + public function setAllowedValues(array $values) : void { + $this->allowedValues = $values; + } /** * Sets a callback method to work as a filter for request parameter. * @@ -677,6 +655,14 @@ public function setMaxValue(float $val) : bool { return false; } + /** + * Sets a custom validation error message for this parameter. + * + * @param string $message The error message to display when validation fails. + */ + public function setMessage(string $message) : void { + $this->message = $message; + } /** * Sets the minimum length that the parameter can accept. * @@ -774,6 +760,22 @@ public function setName(string $name) : bool { return false; } + /** + * Sets a regex pattern for validating the parameter value. + * + * @param string $regex A valid PHP regex (e.g. '/^[a-z]+$/'). + * + * @return bool True if the regex is valid and was set, false otherwise. + */ + public function setPattern(string $regex) : bool { + if (@preg_match($regex, '') !== false) { + $this->pattern = $regex; + + return true; + } + + return false; + } /** * Sets the type of the parameter. * diff --git a/WebFiori/Http/RequestUri.php b/WebFiori/Http/RequestUri.php index e41a509..bb404e1 100644 --- a/WebFiori/Http/RequestUri.php +++ b/WebFiori/Http/RequestUri.php @@ -112,7 +112,7 @@ public function addRequestMethod(string $method) : RequestUri { public function equals(Uri $otherUri) : bool { $thisUri = $this->getAuthority().$this->getPath(); $otherUriStr = $otherUri->getAuthority().$otherUri->getPath(); - + if ($thisUri != $otherUriStr) { return false; } diff --git a/WebFiori/Http/ResponseEntity.php b/WebFiori/Http/ResponseEntity.php index 55c9525..d1230ee 100644 --- a/WebFiori/Http/ResponseEntity.php +++ b/WebFiori/Http/ResponseEntity.php @@ -35,94 +35,83 @@ public function __construct( } /** - * Returns the response body. + * Creates a ResponseEntity with HTTP 400 Bad Request status. * - * @return mixed The body content of the response. - */ - public function getBody(): mixed { - return $this->body; - } - - /** - * Returns the HTTP status code. + * @param mixed $body The response body content describing the error. * - * @return int The HTTP status code. + * @return self A new ResponseEntity instance with status 400. */ - public function getStatus(): int { - return $this->status; + public static function badRequest(mixed $body): self { + return new self($body, 400); } /** - * Returns the content type of the response. + * Creates a ResponseEntity with HTTP 201 Created status. * - * @return string The content type header value. + * @param mixed $body The response body content. + * + * @return self A new ResponseEntity instance with status 201. */ - public function getContentType(): string { - return $this->contentType; + public static function created(mixed $body): self { + return new self($body, 201); } /** - * Creates a ResponseEntity with HTTP 200 OK status. + * Creates a ResponseEntity with HTTP 500 Internal Server Error status. * - * @param mixed $body The response body content. + * @param mixed $body The response body content describing the error. * - * @return self A new ResponseEntity instance with status 200. + * @return self A new ResponseEntity instance with status 500. */ - public static function ok(mixed $body): self { - return new self($body, 200); + public static function error(mixed $body): self { + return new self($body, 500); } /** - * Creates a ResponseEntity with HTTP 201 Created status. + * Creates a ResponseEntity with HTTP 403 Forbidden status. * - * @param mixed $body The response body content. + * @param mixed $body The response body content describing the authorization failure. * - * @return self A new ResponseEntity instance with status 201. + * @return self A new ResponseEntity instance with status 403. */ - public static function created(mixed $body): self { - return new self($body, 201); + public static function forbidden(mixed $body): self { + return new self($body, 403); } /** - * Creates a ResponseEntity with HTTP 204 No Content status and null body. + * Returns the response body. * - * @return self A new ResponseEntity instance with status 204 and no body. + * @return mixed The body content of the response. */ - public static function noContent(): self { - return new self(null, 204); + public function getBody(): mixed { + return $this->body; } /** - * Creates a ResponseEntity with HTTP 400 Bad Request status. - * - * @param mixed $body The response body content describing the error. + * Returns the content type of the response. * - * @return self A new ResponseEntity instance with status 400. + * @return string The content type header value. */ - public static function badRequest(mixed $body): self { - return new self($body, 400); + public function getContentType(): string { + return $this->contentType; } /** - * Creates a ResponseEntity with HTTP 401 Unauthorized status. - * - * @param mixed $body The response body content describing the authentication failure. + * Returns the HTTP status code. * - * @return self A new ResponseEntity instance with status 401. + * @return int The HTTP status code. */ - public static function unauthorized(mixed $body): self { - return new self($body, 401); + public function getStatus(): int { + return $this->status; } /** - * Creates a ResponseEntity with HTTP 403 Forbidden status. - * - * @param mixed $body The response body content describing the authorization failure. + * Creates a ResponseEntity with HTTP 204 No Content status and null body. * - * @return self A new ResponseEntity instance with status 403. + * @return self A new ResponseEntity instance with status 204 and no body. */ - public static function forbidden(mixed $body): self { - return new self($body, 403); + public static function noContent(): self { + return new self(null, 204); } /** @@ -137,13 +126,24 @@ public static function notFound(mixed $body): self { } /** - * Creates a ResponseEntity with HTTP 500 Internal Server Error status. + * Creates a ResponseEntity with HTTP 200 OK status. * - * @param mixed $body The response body content describing the error. + * @param mixed $body The response body content. * - * @return self A new ResponseEntity instance with status 500. + * @return self A new ResponseEntity instance with status 200. */ - public static function error(mixed $body): self { - return new self($body, 500); + public static function ok(mixed $body): self { + return new self($body, 200); + } + + /** + * Creates a ResponseEntity with HTTP 401 Unauthorized status. + * + * @param mixed $body The response body content describing the authentication failure. + * + * @return self A new ResponseEntity instance with status 401. + */ + public static function unauthorized(mixed $body): self { + return new self($body, 401); } } diff --git a/WebFiori/Http/Test/ServiceTestCase.php b/WebFiori/Http/Test/ServiceTestCase.php index 8e5afe0..c0e7984 100644 --- a/WebFiori/Http/Test/ServiceTestCase.php +++ b/WebFiori/Http/Test/ServiceTestCase.php @@ -40,23 +40,27 @@ class ServiceTestCase extends TestCase { private array $globalsBackup; - protected function setUp(): void { - parent::setUp(); - $this->globalsBackup = [ - 'GET' => $_GET, - 'POST' => $_POST, - 'FILES' => $_FILES, - 'SERVER' => $_SERVER, - ]; - } + private function setupGlobals(string $method, array $params, array $headers): void { + $normalizedHeaders = []; - protected function tearDown(): void { - $_GET = $this->globalsBackup['GET']; - $_POST = $this->globalsBackup['POST']; - $_FILES = $this->globalsBackup['FILES']; - $_SERVER = $this->globalsBackup['SERVER']; - SecurityContext::clear(); - parent::tearDown(); + foreach ($headers as $name => $value) { + $normalizedHeaders[strtolower($name)] = $value; + } + + if (in_array($method, [RequestMethod::POST, RequestMethod::PUT, RequestMethod::PATCH])) { + $_POST = $params; + $_SERVER['CONTENT_TYPE'] = $normalizedHeaders['content-type'] ?? 'application/x-www-form-urlencoded'; + } else { + $_GET = $params; + } + + putenv('REQUEST_METHOD='.$method); + + foreach ($normalizedHeaders as $name => $value) { + if ($name !== 'content-type') { + $_SERVER['HTTP_'.strtoupper(str_replace('-', '_', $name))] = $value; + } + } } /** * Send a request to a service with a specific HTTP method. @@ -87,66 +91,62 @@ protected function call(string $method, WebService $service, array $params = [], return new TestResponse($body); } /** - * Send a GET request to a service. + * Send a DELETE request to a service. * * @return TestResponse */ - protected function get(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { - return $this->call(RequestMethod::GET, $service, $params, $user, $headers); + protected function delete(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { + return $this->call(RequestMethod::DELETE, $service, $params, $user, $headers); } /** - * Send a POST request to a service. + * Send a GET request to a service. * * @return TestResponse */ - protected function post(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { - return $this->call(RequestMethod::POST, $service, $params, $user, $headers); + protected function get(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { + return $this->call(RequestMethod::GET, $service, $params, $user, $headers); } /** - * Send a PUT request to a service. + * Send a PATCH request to a service. * * @return TestResponse */ - protected function put(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { - return $this->call(RequestMethod::PUT, $service, $params, $user, $headers); + protected function patch(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { + return $this->call(RequestMethod::PATCH, $service, $params, $user, $headers); } /** - * Send a PATCH request to a service. + * Send a POST request to a service. * * @return TestResponse */ - protected function patch(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { - return $this->call(RequestMethod::PATCH, $service, $params, $user, $headers); + protected function post(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { + return $this->call(RequestMethod::POST, $service, $params, $user, $headers); } /** - * Send a DELETE request to a service. + * Send a PUT request to a service. * * @return TestResponse */ - protected function delete(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { - return $this->call(RequestMethod::DELETE, $service, $params, $user, $headers); + protected function put(WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { + return $this->call(RequestMethod::PUT, $service, $params, $user, $headers); } - private function setupGlobals(string $method, array $params, array $headers): void { - $normalizedHeaders = []; - - foreach ($headers as $name => $value) { - $normalizedHeaders[strtolower($name)] = $value; - } - - if (in_array($method, [RequestMethod::POST, RequestMethod::PUT, RequestMethod::PATCH])) { - $_POST = $params; - $_SERVER['CONTENT_TYPE'] = $normalizedHeaders['content-type'] ?? 'application/x-www-form-urlencoded'; - } else { - $_GET = $params; - } - - putenv('REQUEST_METHOD=' . $method); + protected function setUp(): void { + parent::setUp(); + $this->globalsBackup = [ + 'GET' => $_GET, + 'POST' => $_POST, + 'FILES' => $_FILES, + 'SERVER' => $_SERVER, + ]; + } - foreach ($normalizedHeaders as $name => $value) { - if ($name !== 'content-type') { - $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; - } - } + protected function tearDown(): void { + $_GET = $this->globalsBackup['GET']; + $_POST = $this->globalsBackup['POST']; + $_FILES = $this->globalsBackup['FILES']; + $_SERVER = $this->globalsBackup['SERVER']; + SecurityContext::clear(); + parent::tearDown(); } } diff --git a/WebFiori/Http/Test/TestResponse.php b/WebFiori/Http/Test/TestResponse.php index 13685ec..3d8e4fd 100644 --- a/WebFiori/Http/Test/TestResponse.php +++ b/WebFiori/Http/Test/TestResponse.php @@ -26,57 +26,63 @@ public function __construct(string $body) { $this->json = json_decode($body, true); } /** - * Returns the raw response body. + * Assert the response body contains a substring. * - * @return string + * @return self */ - public function getBody(): string { - return $this->body; + public function assertBodyContains(string $substring): self { + Assert::assertStringContainsString($substring, $this->body); + + return $this; } /** - * Returns the decoded JSON body, or null if not valid JSON. + * Assert the response type is 'error'. * - * @return array|null + * @return self */ - public function getJson(): ?array { - return $this->json; + public function assertError(): self { + return $this->assertJsonEquals('type', 'error'); } /** - * Returns the HTTP status code from the JSON response. + * Assert the response body is valid JSON. * - * @return int + * @return self */ - public function getStatusCode(): int { - return $this->json['http-code'] ?? 200; + public function assertJson(): self { + Assert::assertNotNull($this->json, 'Response body is not valid JSON'); + + return $this; } /** - * Assert the response has a specific HTTP status code. + * Assert a JSON key equals an expected value. * * @return self */ - public function assertStatus(int $code): self { - Assert::assertEquals($code, $this->getStatusCode(), "Expected status $code, got {$this->getStatusCode()}"); + public function assertJsonEquals(string $key, mixed $expected): self { + Assert::assertNotNull($this->json, 'Response body is not valid JSON'); + Assert::assertArrayHasKey($key, $this->json, "JSON response missing key '$key'"); + Assert::assertEquals($expected, $this->json[$key], "JSON key '$key' does not match expected value"); + return $this; } /** - * Assert the response is successful (no error status). + * Assert the JSON response contains a specific key. * * @return self */ - public function assertOk(): self { - Assert::assertFalse( - isset($this->json['http-code']) && $this->json['http-code'] >= 400, - "Expected successful response, got status {$this->getStatusCode()}" - ); + public function assertJsonHas(string $key): self { + Assert::assertNotNull($this->json, 'Response body is not valid JSON'); + Assert::assertArrayHasKey($key, $this->json, "JSON response missing key '$key'"); + return $this; } /** - * Assert the response is 401 Unauthorized. + * Assert the response is 405 Method Not Allowed. * * @return self */ - public function assertUnauthorized(): self { - return $this->assertStatus(401); + public function assertMethodNotAllowed(): self { + return $this->assertStatus(405); } /** * Assert the response is 404 Not Found. @@ -87,58 +93,58 @@ public function assertNotFound(): self { return $this->assertStatus(404); } /** - * Assert the response is 405 Method Not Allowed. + * Assert the response is successful (no error status). * * @return self */ - public function assertMethodNotAllowed(): self { - return $this->assertStatus(405); + public function assertOk(): self { + Assert::assertFalse( + isset($this->json['http-code']) && $this->json['http-code'] >= 400, + "Expected successful response, got status {$this->getStatusCode()}" + ); + + return $this; } /** - * Assert the response body is valid JSON. + * Assert the response has a specific HTTP status code. * * @return self */ - public function assertJson(): self { - Assert::assertNotNull($this->json, 'Response body is not valid JSON'); + public function assertStatus(int $code): self { + Assert::assertEquals($code, $this->getStatusCode(), "Expected status $code, got {$this->getStatusCode()}"); + return $this; } /** - * Assert the JSON response contains a specific key. + * Assert the response is 401 Unauthorized. * * @return self */ - public function assertJsonHas(string $key): self { - Assert::assertNotNull($this->json, 'Response body is not valid JSON'); - Assert::assertArrayHasKey($key, $this->json, "JSON response missing key '$key'"); - return $this; + public function assertUnauthorized(): self { + return $this->assertStatus(401); } /** - * Assert a JSON key equals an expected value. + * Returns the raw response body. * - * @return self + * @return string */ - public function assertJsonEquals(string $key, mixed $expected): self { - Assert::assertNotNull($this->json, 'Response body is not valid JSON'); - Assert::assertArrayHasKey($key, $this->json, "JSON response missing key '$key'"); - Assert::assertEquals($expected, $this->json[$key], "JSON key '$key' does not match expected value"); - return $this; + public function getBody(): string { + return $this->body; } /** - * Assert the response body contains a substring. + * Returns the decoded JSON body, or null if not valid JSON. * - * @return self + * @return array|null */ - public function assertBodyContains(string $substring): self { - Assert::assertStringContainsString($substring, $this->body); - return $this; + public function getJson(): ?array { + return $this->json; } /** - * Assert the response type is 'error'. + * Returns the HTTP status code from the JSON response. * - * @return self + * @return int */ - public function assertError(): self { - return $this->assertJsonEquals('type', 'error'); + public function getStatusCode(): int { + return $this->json['http-code'] ?? 200; } } diff --git a/WebFiori/Http/WebService.php b/WebFiori/Http/WebService.php index e8c5728..0190457 100644 --- a/WebFiori/Http/WebService.php +++ b/WebFiori/Http/WebService.php @@ -57,6 +57,12 @@ class WebService implements JsonI { * */ private $name; + /** + * The content type negotiated from the Accept header. + * + * @var string|null + */ + private $negotiatedContentType; /** * The manager that the service belongs to. * @@ -93,12 +99,6 @@ class WebService implements JsonI { * */ private $requireAuth; - /** - * The content type negotiated from the Accept header. - * - * @var string|null - */ - private $negotiatedContentType; /** * An array that contains descriptions of * possible responses. @@ -116,19 +116,19 @@ class WebService implements JsonI { */ private $serviceDesc; /** - * An attribute that is used to tell since which API version the - * service was added. + * Custom route path for this service. * * @var string - * */ - private $sinceVersion; + private string $servicePath = ''; /** - * Custom route path for this service. + * An attribute that is used to tell since which API version the + * service was added. * * @var string + * */ - private string $servicePath = ''; + private $sinceVersion; /** * Creates new instance of the class. * @@ -472,29 +472,15 @@ public final function getName() : string { return $this->name; } /** - * Returns the custom route path for this service. - * - * If a path is set, it will be used for URL routing and OpenAPI spec - * generation instead of the service name. If not set, falls back to - * the service name. - * - * @return string The custom path, or the service name if no path is set. - */ - public final function getPath() : string { - return $this->servicePath !== '' ? $this->servicePath : $this->name; - } - /** - * Sets a custom route path for this service. - * - * The path may contain slashes for multi-segment URLs (e.g., 'auth/login'). + * Returns the content type negotiated from the client's Accept header. * - * @param string $path The route path. + * Only meaningful when the method has a #[Produces] attribute. + * Defaults to 'application/json' if no negotiation occurred. * - * @return self + * @return string The negotiated media type. */ - public function setPath(string $path) : self { - $this->servicePath = trim($path, '/'); - return $this; + public function getNegotiatedContentType() : string { + return $this->negotiatedContentType ?? MediaType::JSON; } /** * Map service parameter to specific instance of a class. @@ -579,6 +565,18 @@ public function getParamVal(string $paramName) { return null; } + /** + * Returns the custom route path for this service. + * + * If a path is set, it will be used for URL routing and OpenAPI spec + * generation instead of the service name. If not set, falls back to + * the service name. + * + * @return string The custom path, or the service name if no path is set. + */ + public final function getPath() : string { + return $this->servicePath !== '' ? $this->servicePath : $this->name; + } /** * Returns an indexed array that contains information about possible responses. * @@ -647,6 +645,16 @@ public function getTargetMethod(): ?string { return null; } + /** + * Checks if the class has a #[RequiresAuth] attribute. + * + * @return bool True if the class-level RequiresAuth annotation is present. + */ + public function hasClassLevelRequiresAuth() : bool { + $reflection = new \ReflectionClass($this); + + return !empty($reflection->getAttributes(Annotations\RequiresAuth::class)); + } /** * Check if the method has any authorization annotations. @@ -736,27 +744,6 @@ public function isAuthorized() : string|bool { public function isAuthRequired() : bool { return $this->requireAuth; } - /** - * Returns the content type negotiated from the client's Accept header. - * - * Only meaningful when the method has a #[Produces] attribute. - * Defaults to 'application/json' if no negotiation occurred. - * - * @return string The negotiated media type. - */ - public function getNegotiatedContentType() : string { - return $this->negotiatedContentType ?? MediaType::JSON; - } - /** - * Checks if the class has a #[RequiresAuth] attribute. - * - * @return bool True if the class-level RequiresAuth annotation is present. - */ - public function hasClassLevelRequiresAuth() : bool { - $reflection = new \ReflectionClass($this); - - return !empty($reflection->getAttributes(Annotations\RequiresAuth::class)); - } /** * Validates the name of a web service or request parameter. @@ -788,21 +775,6 @@ public static function isValidName(string $name): bool { */ public function processRequest() { } - /** - * Service-wide cross-field validation hook. - * - * Override this method to add validation rules that depend on multiple - * parameters together. Called after individual parameter validation passes - * but before the request method is invoked. - * - * @param array $inputs The filtered input values. - * - * @return array An associative array of errors keyed by field name. - * Return empty array if validation passes. - */ - public function validate(array $inputs): array { - return []; - } /** * Process the web service request with auto-processing support. @@ -839,7 +811,7 @@ public function processWithAutoHandling(): void { $validationErrors = $this->runValidation($targetMethod); if (!empty($validationErrors)) { - $this->sendResponse('Validation failed', 422, 'error', new \WebFiori\Json\Json([ + $this->sendResponse('Validation failed', 422, 'error', new Json([ 'errors' => $validationErrors ])); @@ -1058,6 +1030,20 @@ public final function setName(string $name) : bool { return false; } + /** + * Sets a custom route path for this service. + * + * The path may contain slashes for multi-segment URLs (e.g., 'auth/login'). + * + * @param string $path The route path. + * + * @return self + */ + public function setPath(string $path) : self { + $this->servicePath = trim($path, '/'); + + return $this; + } /** * Sets the request instance for the service. @@ -1123,6 +1109,8 @@ public function toPathItemObj(): OpenAPI\PathItemObj { $pathItem = new OpenAPI\PathItemObj(); $annotatedParams = $this->getAnnotatedRequestParams(); $annotatedResponses = $this->getAnnotatedApiResponses(); + $consumesMap = $this->getAnnotatedConsumes(); + $producesMap = $this->getAnnotatedProduces(); foreach ($this->getRequestMethods() as $method) { $responses = $this->getResponsesForMethod($method); @@ -1132,12 +1120,20 @@ public function toPathItemObj(): OpenAPI\PathItemObj { $responses = $annotatedResponses[$method]; } else { $responses = new OpenAPI\ResponsesObj(); - $responses->addResponse('200', 'Successful operation'); + $producedTypes = $producesMap[$method] ?? null; + + if ($producedTypes !== null) { + $desc = 'Successful operation. Produces: '.implode(', ', $producedTypes); + $responses->addResponse('200', $desc); + } else { + $responses->addResponse('200', 'Successful operation'); + } } } $operation = new OpenAPI\OperationObj($responses); $methodParams = $annotatedParams[$method] ?? []; + $consumesTypes = $consumesMap[$method] ?? null; if (!empty($methodParams)) { $isBodyMethod = in_array($method, [ @@ -1148,7 +1144,7 @@ public function toPathItemObj(): OpenAPI\PathItemObj { if ($isBodyMethod) { $operation->setRequestBody( - self::buildRequestBody($methodParams) + self::buildRequestBody($methodParams, $consumesTypes) ); } else { foreach ($methodParams as $param) { @@ -1157,6 +1153,13 @@ public function toPathItemObj(): OpenAPI\PathItemObj { ); } } + } else if ($consumesTypes !== null && in_array($method, [ + RequestMethod::POST, RequestMethod::PUT, RequestMethod::PATCH + ])) { + // No request params but #[Consumes] is present (e.g. raw binary upload) + $operation->setRequestBody( + self::buildRawRequestBody($consumesTypes) + ); } switch ($method) { @@ -1180,82 +1183,39 @@ public function toPathItemObj(): OpenAPI\PathItemObj { return $pathItem; } - /** - * Reads #[RequestParam] annotations from methods and groups them by HTTP method. - * - * @return array Map of HTTP method to RequestParam annotations. + * Service-wide cross-field validation hook. + * + * Override this method to add validation rules that depend on multiple + * parameters together. Called after individual parameter validation passes + * but before the request method is invoked. + * + * @param array $inputs The filtered input values. + * + * @return array An associative array of errors keyed by field name. + * Return empty array if validation passes. */ - private function getAnnotatedRequestParams(): array { - $reflection = new \ReflectionClass($this); - $result = []; - - $mappings = [ - Annotations\GetMapping::class => RequestMethod::GET, - Annotations\PostMapping::class => RequestMethod::POST, - Annotations\PutMapping::class => RequestMethod::PUT, - Annotations\DeleteMapping::class => RequestMethod::DELETE, - Annotations\PatchMapping::class => RequestMethod::PATCH, - ]; - - foreach ($reflection->getMethods() as $method) { - $paramAttrs = $method->getAttributes(Annotations\RequestParam::class); - - if (empty($paramAttrs)) { - continue; - } - - $params = array_map(fn($a) => $a->newInstance(), $paramAttrs); - - foreach ($mappings as $annotationClass => $httpMethod) { - if (!empty($method->getAttributes($annotationClass))) { - $result[$httpMethod] = array_merge($result[$httpMethod] ?? [], $params); - } - } - } - - return $result; + public function validate(array $inputs): array { + return []; } /** - * Reads #[ApiResponse] annotations from methods and groups them by HTTP method. - * - * @return array Map of HTTP method to ResponsesObj. + * Builds an OpenAPI Schema from a RequestParam annotation. */ - private function getAnnotatedApiResponses(): array { - $reflection = new \ReflectionClass($this); - $result = []; - - $mappings = [ - Annotations\GetMapping::class => RequestMethod::GET, - Annotations\PostMapping::class => RequestMethod::POST, - Annotations\PutMapping::class => RequestMethod::PUT, - Annotations\DeleteMapping::class => RequestMethod::DELETE, - Annotations\PatchMapping::class => RequestMethod::PATCH, - ]; - - foreach ($reflection->getMethods() as $method) { - $responseAttrs = $method->getAttributes(Annotations\ApiResponse::class); - - if (empty($responseAttrs)) { - continue; - } + private static function buildParamSchema(Annotations\RequestParam $param): OpenAPI\Schema { + $schema = new OpenAPI\Schema(OpenAPI\Schema::mapType($param->type)); - foreach ($mappings as $annotationClass => $httpMethod) { - if (!empty($method->getAttributes($annotationClass))) { - if (!isset($result[$httpMethod])) { - $result[$httpMethod] = new OpenAPI\ResponsesObj(); - } + if ($param->type === ParamType::EMAIL) { + $schema->setFormat('email'); + } else if ($param->type === ParamType::URL) { + $schema->setFormat('uri'); + } - foreach ($responseAttrs as $attr) { - $instance = $attr->newInstance(); - $result[$httpMethod]->addResponse($instance->status, $instance->description); - } - } - } + if ($param->default !== null) { + // Schema doesn't have a public setter for default, build inline } - return $result; + return $schema; } /** @@ -1272,14 +1232,40 @@ private static function buildQueryParameter(Annotations\RequestParam $param): Op return $p; } + /** + * Builds an OpenAPI requestBody Json for raw body endpoints (no parameters). + * + * Used when #[Consumes] is present but no #[RequestParam] annotations exist + * (e.g. binary upload endpoints). + * + * @param array $consumesTypes Content types from #[Consumes]. + */ + private static function buildRawRequestBody(array $consumesTypes): Json { + $content = new Json(); + + foreach ($consumesTypes as $type) { + $mediaType = new Json(); + $schema = new Json(); + $schema->add('type', 'string'); + $schema->add('format', 'binary'); + $mediaType->add('schema', $schema); + $content->add($type, $mediaType); + } + + $body = new Json(); + $body->add('content', $content); + + return $body; + } /** * Builds an OpenAPI requestBody Json object from RequestParam annotations. * * @param Annotations\RequestParam[] $params + * @param array|null $consumesTypes Content types from #[Consumes], or null for default. */ - private static function buildRequestBody(array $params): \WebFiori\Json\Json { - $properties = new \WebFiori\Json\Json(); + private static function buildRequestBody(array $params, ?array $consumesTypes = null): Json { + $properties = new Json(); $required = []; foreach ($params as $param) { @@ -1290,7 +1276,7 @@ private static function buildRequestBody(array $params): \WebFiori\Json\Json { } } - $schema = new \WebFiori\Json\Json(); + $schema = new Json(); $schema->add('type', 'object'); $schema->add('properties', $properties); @@ -1298,34 +1284,21 @@ private static function buildRequestBody(array $params): \WebFiori\Json\Json { $schema->add('required', $required); } - $content = new \WebFiori\Json\Json(); - $mediaType = new \WebFiori\Json\Json(); + $content = new Json(); + $mediaType = new Json(); $mediaType->add('schema', $schema); - $content->add('application/x-www-form-urlencoded', $mediaType); - - $body = new \WebFiori\Json\Json(); - $body->add('content', $content); - - return $body; - } - /** - * Builds an OpenAPI Schema from a RequestParam annotation. - */ - private static function buildParamSchema(Annotations\RequestParam $param): OpenAPI\Schema { - $schema = new OpenAPI\Schema(OpenAPI\Schema::mapType($param->type)); + // Use #[Consumes] types if available, otherwise default + $types = $consumesTypes ?? ['application/x-www-form-urlencoded']; - if ($param->type === ParamType::EMAIL) { - $schema->setFormat('email'); - } else if ($param->type === ParamType::URL) { - $schema->setFormat('uri'); + foreach ($types as $type) { + $content->add($type, $mediaType); } - if ($param->default !== null) { - // Schema doesn't have a public setter for default, build inline - } + $body = new Json(); + $body->add('content', $content); - return $schema; + return $body; } /** @@ -1457,7 +1430,7 @@ private function configureParametersForHttpMethod(string $httpMethod): void { 'POST' => PostMapping::class, 'PUT' => PutMapping::class, 'DELETE' => DeleteMapping::class, - 'PATCH' => Annotations\PatchMapping::class, + 'PATCH' => PatchMapping::class, ]; if (isset($annotations[$httpMethod])) { @@ -1469,42 +1442,63 @@ private function configureParametersForHttpMethod(string $httpMethod): void { } } } - /** - * Performs content negotiation for a method. - * - * @param string $methodName The target method name. - * - * @return string|null The negotiated content type, or null if no match (406). + * Configure parameters from method RequestParam annotations. */ - private function negotiateContentType(string $methodName): ?string { - $reflection = new \ReflectionMethod($this, $methodName); - $producesAttrs = $reflection->getAttributes(Annotations\Produces::class); + private function configureParametersFromMethod(\ReflectionMethod $method): void { + // Process #[UseParameterSet] attributes first + $setAttributes = $method->getAttributes(Annotations\UseParameterSet::class); - if (empty($producesAttrs)) { - return MediaType::JSON; - } + foreach ($setAttributes as $setAttr) { + $setAnnotation = $setAttr->newInstance(); + $className = $setAnnotation->class; - $produces = $producesAttrs[0]->newInstance()->contentTypes; - $acceptHeader = $this->getAcceptHeader(); + if (class_exists($className)) { + $setInstance = new $className(); - if (empty($acceptHeader)) { - return $produces[0]; + if ($setInstance instanceof ParameterSet) { + $this->addParameterSet($setInstance); + } + } } - $accepted = self::parseAcceptHeader($acceptHeader); + // Then process #[RequestParam] attributes + $paramAttributes = $method->getAttributes(Annotations\RequestParam::class); - foreach ($accepted as $mediaType) { - if ($mediaType['type'] === '*/*' || $mediaType['type'] === 'application/*') { - return $produces[0]; + foreach ($paramAttributes as $attribute) { + $param = $attribute->newInstance(); + + $options = [ + ParamOption::TYPE => $this->mapParamType($param->type), + ParamOption::OPTIONAL => $param->optional, + ParamOption::DEFAULT => $param->default, + ParamOption::DESCRIPTION => $param->description + ]; + + if ($param->filter !== null) { + $options[ParamOption::FILTER] = $param->filter; } - if (in_array($mediaType['type'], $produces, true)) { - return $mediaType['type']; + if (!empty($param->allowedValues)) { + $options[ParamOption::ALLOWED_VALUES] = $param->allowedValues; } - } - return null; + if ($param->pattern !== null) { + $options[ParamOption::PATTERN] = $param->pattern; + } + + if ($param->message !== null) { + $options[ParamOption::MESSAGE] = $param->message; + } + + if ($param->allowEmpty) { + $options[ParamOption::EMPTY] = true; + } + + $this->addParameters([ + $param->name => $options + ]); + } } /** * Gets the Accept header value from the current request. @@ -1520,140 +1514,154 @@ private function getAcceptHeader(): string { return $_SERVER['HTTP_ACCEPT'] ?? ''; } + /** - * Parses an Accept header into a sorted list of media types by q-value. - * - * @param string $header The raw Accept header value. - * - * @return array Sorted array of ['type' => string, 'q' => float]. + * Reads #[ApiResponse] annotations from methods and groups them by HTTP method. + * + * @return array Map of HTTP method to ResponsesObj. */ - private static function parseAcceptHeader(string $header): array { - $types = []; + private function getAnnotatedApiResponses(): array { + $reflection = new \ReflectionClass($this); + $result = []; - foreach (explode(',', $header) as $part) { - $segments = explode(';', trim($part)); - $mediaType = trim($segments[0]); - $q = 1.0; + $mappings = [ + GetMapping::class => RequestMethod::GET, + PostMapping::class => RequestMethod::POST, + PutMapping::class => RequestMethod::PUT, + DeleteMapping::class => RequestMethod::DELETE, + PatchMapping::class => RequestMethod::PATCH, + ]; - foreach ($segments as $segment) { - $segment = trim($segment); + foreach ($reflection->getMethods() as $method) { + $responseAttrs = $method->getAttributes(Annotations\ApiResponse::class); - if (str_starts_with($segment, 'q=')) { - $q = (float) substr($segment, 2); - } + if (empty($responseAttrs)) { + continue; } - $types[] = ['type' => $mediaType, 'q' => $q]; - } + foreach ($mappings as $annotationClass => $httpMethod) { + if (!empty($method->getAttributes($annotationClass))) { + if (!isset($result[$httpMethod])) { + $result[$httpMethod] = new OpenAPI\ResponsesObj(); + } - usort($types, fn($a, $b) => $b['q'] <=> $a['q']); + foreach ($responseAttrs as $attr) { + $instance = $attr->newInstance(); + $result[$httpMethod]->addResponse($instance->status, $instance->description); + } + } + } + } - return $types; + return $result; } + /** - * Runs cross-field validation: service-wide validate() + method-specific #[Validate]. - * - * @param string $targetMethod The method being invoked. - * - * @return array Merged errors from both validators. Empty if all pass. + * Reads #[Consumes] annotations from methods and groups them by HTTP method. + * + * @return array Map of HTTP method to content type arrays. */ - private function runValidation(string $targetMethod): array { - $inputs = $this->getInputs(); - - if ($inputs instanceof \WebFiori\Json\Json) { - $inputsArray = []; - - foreach ($inputs->getPropsNames() as $name) { - $inputsArray[$name] = $inputs->get($name); - } - } else { - $inputsArray = is_array($inputs) ? $inputs : []; - } - - // 1. Service-wide validation - $errors = $this->validate($inputsArray); + private function getAnnotatedConsumes(): array { + $reflection = new \ReflectionClass($this); + $result = []; - // 2. Method-specific #[Validate] attribute - $reflection = new \ReflectionMethod($this, $targetMethod); - $validateAttrs = $reflection->getAttributes(Annotations\Validate::class); + $mappings = [ + GetMapping::class => RequestMethod::GET, + PostMapping::class => RequestMethod::POST, + PutMapping::class => RequestMethod::PUT, + DeleteMapping::class => RequestMethod::DELETE, + PatchMapping::class => RequestMethod::PATCH, + ]; - if (!empty($validateAttrs)) { - $validateAnnotation = $validateAttrs[0]->newInstance(); - $validatorMethod = $validateAnnotation->method; + foreach ($reflection->getMethods() as $method) { + $consumesAttrs = $method->getAttributes(Annotations\Consumes::class); - if (!method_exists($this, $validatorMethod)) { - throw new \InvalidArgumentException( - "Validation method '$validatorMethod' referenced by #[Validate] does not exist on " . get_class($this) - ); + if (empty($consumesAttrs)) { + continue; } - $validatorReflection = new \ReflectionMethod($this, $validatorMethod); - $validatorReflection->setAccessible(true); - $methodErrors = $validatorReflection->invoke($this, $inputsArray); + $types = $consumesAttrs[0]->newInstance()->contentTypes; - if (is_array($methodErrors)) { - $errors = array_merge($errors, $methodErrors); + foreach ($mappings as $annotationClass => $httpMethod) { + if (!empty($method->getAttributes($annotationClass))) { + $result[$httpMethod] = $types; + } } } - return $errors; + return $result; } + /** - * Configure parameters from method RequestParam annotations. + * Reads #[Produces] annotations from methods and groups them by HTTP method. + * + * @return array Map of HTTP method to content type arrays. */ - private function configureParametersFromMethod(\ReflectionMethod $method): void { - // Process #[UseParameterSet] attributes first - $setAttributes = $method->getAttributes(Annotations\UseParameterSet::class); + private function getAnnotatedProduces(): array { + $reflection = new \ReflectionClass($this); + $result = []; - foreach ($setAttributes as $setAttr) { - $setAnnotation = $setAttr->newInstance(); - $className = $setAnnotation->class; + $mappings = [ + GetMapping::class => RequestMethod::GET, + PostMapping::class => RequestMethod::POST, + PutMapping::class => RequestMethod::PUT, + DeleteMapping::class => RequestMethod::DELETE, + PatchMapping::class => RequestMethod::PATCH, + ]; - if (class_exists($className)) { - $setInstance = new $className(); + foreach ($reflection->getMethods() as $method) { + $producesAttrs = $method->getAttributes(Annotations\Produces::class); - if ($setInstance instanceof ParameterSet) { - $this->addParameterSet($setInstance); + if (empty($producesAttrs)) { + continue; + } + + $types = $producesAttrs[0]->newInstance()->contentTypes; + + foreach ($mappings as $annotationClass => $httpMethod) { + if (!empty($method->getAttributes($annotationClass))) { + $result[$httpMethod] = $types; } } } - // Then process #[RequestParam] attributes - $paramAttributes = $method->getAttributes(Annotations\RequestParam::class); + return $result; + } - foreach ($paramAttributes as $attribute) { - $param = $attribute->newInstance(); + /** + * Reads #[RequestParam] annotations from methods and groups them by HTTP method. + * + * @return array Map of HTTP method to RequestParam annotations. + */ + private function getAnnotatedRequestParams(): array { + $reflection = new \ReflectionClass($this); + $result = []; - $options = [ - ParamOption::TYPE => $this->mapParamType($param->type), - ParamOption::OPTIONAL => $param->optional, - ParamOption::DEFAULT => $param->default, - ParamOption::DESCRIPTION => $param->description - ]; + $mappings = [ + GetMapping::class => RequestMethod::GET, + PostMapping::class => RequestMethod::POST, + PutMapping::class => RequestMethod::PUT, + DeleteMapping::class => RequestMethod::DELETE, + PatchMapping::class => RequestMethod::PATCH, + ]; - if ($param->filter !== null) { - $options[ParamOption::FILTER] = $param->filter; - } + foreach ($reflection->getMethods() as $method) { + $paramAttrs = $method->getAttributes(Annotations\RequestParam::class); - if (!empty($param->allowedValues)) { - $options[ParamOption::ALLOWED_VALUES] = $param->allowedValues; + if (empty($paramAttrs)) { + continue; } - if ($param->pattern !== null) { - $options[ParamOption::PATTERN] = $param->pattern; - } + $params = array_map(fn($a) => $a->newInstance(), $paramAttrs); - if ($param->message !== null) { - $options[ParamOption::MESSAGE] = $param->message; - } - if ($param->allowEmpty) { - $options[ParamOption::EMPTY] = true; + foreach ($mappings as $annotationClass => $httpMethod) { + if (!empty($method->getAttributes($annotationClass))) { + $result[$httpMethod] = array_merge($result[$httpMethod] ?? [], $params); + } } - - $this->addParameters([ - $param->name => $options - ]); } + + return $result; } /** @@ -1783,6 +1791,121 @@ private function methodHandlesHttpMethod(\ReflectionMethod $method, string $http return false; } + /** + * Performs content negotiation for a method. + * + * @param string $methodName The target method name. + * + * @return string|null The negotiated content type, or null if no match (406). + */ + private function negotiateContentType(string $methodName): ?string { + $reflection = new \ReflectionMethod($this, $methodName); + $producesAttrs = $reflection->getAttributes(Annotations\Produces::class); + + if (empty($producesAttrs)) { + return MediaType::JSON; + } + + $produces = $producesAttrs[0]->newInstance()->contentTypes; + $acceptHeader = $this->getAcceptHeader(); + + if (empty($acceptHeader)) { + return $produces[0]; + } + + $accepted = self::parseAcceptHeader($acceptHeader); + + foreach ($accepted as $mediaType) { + if ($mediaType['type'] === '*/*' || $mediaType['type'] === 'application/*') { + return $produces[0]; + } + + if (in_array($mediaType['type'], $produces, true)) { + return $mediaType['type']; + } + } + + return null; + } + /** + * Parses an Accept header into a sorted list of media types by q-value. + * + * @param string $header The raw Accept header value. + * + * @return array Sorted array of ['type' => string, 'q' => float]. + */ + private static function parseAcceptHeader(string $header): array { + $types = []; + + foreach (explode(',', $header) as $part) { + $segments = explode(';', trim($part)); + $mediaType = trim($segments[0]); + $q = 1.0; + + foreach ($segments as $segment) { + $segment = trim($segment); + + if (str_starts_with($segment, 'q=')) { + $q = (float) substr($segment, 2); + } + } + + $types[] = ['type' => $mediaType, 'q' => $q]; + } + + usort($types, fn($a, $b) => $b['q'] <=> $a['q']); + + return $types; + } + /** + * Runs cross-field validation: service-wide validate() + method-specific #[Validate]. + * + * @param string $targetMethod The method being invoked. + * + * @return array Merged errors from both validators. Empty if all pass. + */ + private function runValidation(string $targetMethod): array { + $inputs = $this->getInputs(); + + if ($inputs instanceof Json) { + $inputsArray = []; + + foreach ($inputs->getPropsNames() as $name) { + $inputsArray[$name] = $inputs->get($name); + } + } else { + $inputsArray = is_array($inputs) ? $inputs : []; + } + + // 1. Service-wide validation + $errors = $this->validate($inputsArray); + + // 2. Method-specific #[Validate] attribute + $reflection = new \ReflectionMethod($this, $targetMethod); + $validateAttrs = $reflection->getAttributes(Annotations\Validate::class); + + if (!empty($validateAttrs)) { + $validateAnnotation = $validateAttrs[0]->newInstance(); + $validatorMethod = $validateAnnotation->method; + + if (!method_exists($this, $validatorMethod)) { + throw new \InvalidArgumentException( + "Validation method '$validatorMethod' referenced by #[Validate] does not exist on ".get_class($this) + ); + } + + $validatorReflection = new \ReflectionMethod($this, $validatorMethod); + $validatorReflection->setAccessible(true); + $methodErrors = $validatorReflection->invoke($this, $inputsArray); + + if (is_array($methodErrors)) { + $errors = array_merge($errors, $methodErrors); + } + } + + return $errors; + } + /** * Get the current processing method name (to be overridden by subclasses if needed). */ @@ -1825,9 +1948,9 @@ protected function handleMethodResponse(mixed $result, string $methodName): void if ($contentType !== 'application/json') { // For non-JSON content types, send raw result if ($result instanceof Json) { - $this->send($contentType, $result . '', $responseBody->status); + $this->send($contentType, $result.'', $responseBody->status); } else if ($result instanceof JsonI) { - $this->send($contentType, $result->toJSON() . '', $responseBody->status); + $this->send($contentType, $result->toJSON().'', $responseBody->status); } else if (is_array($result)) { $content = new Json(); $content->addArray('data', $result, !array_is_list($result)); @@ -1848,6 +1971,7 @@ protected function handleMethodResponse(mixed $result, string $methodName): void // Handle ResponseEntity for dynamic status codes if ($result instanceof ResponseEntity) { $body = $result->getBody(); + if ($body === null) { $this->send($result->getContentType(), "", $result->getStatus()); } else if ($body instanceof Json || $body instanceof JsonI) { @@ -1861,6 +1985,7 @@ protected function handleMethodResponse(mixed $result, string $methodName): void } else { $this->send($result->getContentType(), $body, $result->getStatus()); } + return; } @@ -1869,9 +1994,9 @@ protected function handleMethodResponse(mixed $result, string $methodName): void // Null return = empty response with configured status $this->sendResponse('', $responseBody->status, $responseBody->type); } else if ($result instanceof Json) { - $this->send($responseBody->contentType, $result . '', $responseBody->status); + $this->send($responseBody->contentType, $result.'', $responseBody->status); } else if ($result instanceof JsonI) { - $this->send($responseBody->contentType, $result->toJSON() . '', $responseBody->status); + $this->send($responseBody->contentType, $result->toJSON().'', $responseBody->status); } else if (is_array($result) || is_object($result)) { $json = new Json(); $asObj = is_array($result) && !array_is_list($result); diff --git a/WebFiori/Http/WebServicesManager.php b/WebFiori/Http/WebServicesManager.php index a42752d..d09d8c5 100644 --- a/WebFiori/Http/WebServicesManager.php +++ b/WebFiori/Http/WebServicesManager.php @@ -414,18 +414,41 @@ public function invParams() { * Checks if request content type is supported by the service or not (For 'POST' * and PUT requests only). * + * This performs a baseline check against the default allowed types. If a service + * method has a #[Consumes] annotation, that check is performed later after service + * resolution via isContentTypeAllowedForService(). + * + * @param WebService|null $service If provided, checks #[Consumes] on the service's + * target method. If the annotation is present, its types override the defaults. + * * @return bool Returns false in case the 'content-type' header is not * set and the request method is 'POST' or 'PUT'. If content type is supported (for * PUT and POST), the method will return true, false if not. Other than that, the method * will return true. * */ - public final function isContentTypeSupported() : bool { + public final function isContentTypeSupported(?WebService $service = null) : bool { $c = $this->getRequest()->getContentType(); $rm = $this->getRequest()->getMethod(); if ($c !== null && ($rm == RequestMethod::POST || $rm == RequestMethod::PUT)) { - // Check if content type starts with any of the supported types + // If a service is provided, check its #[Consumes] annotation first + if ($service !== null) { + $consumesTypes = $this->getConsumesTypes($service); + + if ($consumesTypes !== null) { + // #[Consumes] is present - validate against its types only + foreach ($consumesTypes as $allowedType) { + if (strpos($c, $allowedType) === 0) { + return true; + } + } + + return false; + } + } + + // No #[Consumes] annotation - check default types foreach (self::POST_CONTENT_TYPES as $supportedType) { if (strpos($c, $supportedType) === 0) { return true; @@ -513,48 +536,64 @@ public final function process() { $this->invParamsArr = []; $this->missingParamsArr = []; - if ($this->isContentTypeSupported()) { - if ($this->_checkAction()) { - $actionObj = $this->getServiceByName($this->getCalledServiceName()); + $c = $this->getRequest()->getContentType(); + $rm = $this->getRequest()->getMethod(); - // Configure parameters for ResponseBody services before getting them - if ($this->serviceHasResponseBodyMethods($actionObj)) { - $this->configureServiceParameters($actionObj); - } + // Early rejection: POST/PUT with no content-type header at all + if ($c === null && ($rm == RequestMethod::POST || $rm == RequestMethod::PUT)) { + $this->contentTypeNotSupported('NOT_SET'); - // Resolve #[RequestParam] annotations for the current HTTP method. - // This ensures annotated parameters are registered before filtering, - // even for services using the traditional processRequest() pattern. - $actionObj->getParameterByName('', $this->getRequest()->getRequestMethod()); + return; + } - $params = $actionObj->getParameters(); - $this->filter->clearParametersDef(); - $this->filter->clearInputs(); - $requestMethod = $this->getRequest()->getRequestMethod(); + if ($this->_checkAction()) { + $actionObj = $this->getServiceByName($this->getCalledServiceName()); - foreach ($params as $param) { - $paramMethods = $param->getMethods(); + // Check content type with #[Consumes] annotation awareness + if (!$this->isContentTypeSupported($actionObj)) { + $this->contentTypeNotSupported($c ?? 'NOT_SET'); - if (count($paramMethods) == 0 || in_array($requestMethod, $paramMethods)) { - $this->filter->addRequestParameter($param); - } - } - $this->filterInputsHelper(); - $i = $this->getInputs(); + return; + } - if (!($i instanceof Json)) { - $this->_processNonJson($this->filter->getParameters()); - } else { - $this->_processJson($this->filter->getParameters()); + // Configure parameters for ResponseBody services before getting them + if ($this->serviceHasResponseBodyMethods($actionObj)) { + $this->configureServiceParameters($actionObj); + } + + // If content type is non-parseable (e.g. octet-stream allowed by #[Consumes]), + // skip parameter filtering and dispatch directly (POST/PUT only) + if (($rm == RequestMethod::POST || $rm == RequestMethod::PUT) && !$this->isParseableContentType()) { + $this->processService($actionObj); + + return; + } + + // Resolve #[RequestParam] annotations for the current HTTP method. + // This ensures annotated parameters are registered before filtering, + // even for services using the traditional processRequest() pattern. + $actionObj->getParameterByName('', $this->getRequest()->getRequestMethod()); + + $params = $actionObj->getParameters(); + $this->filter->clearParametersDef(); + $this->filter->clearInputs(); + $requestMethod = $this->getRequest()->getRequestMethod(); + + foreach ($params as $param) { + $paramMethods = $param->getMethods(); + + if (count($paramMethods) == 0 || in_array($requestMethod, $paramMethods)) { + $this->filter->addRequestParameter($param); } } - } else { - $c = $this->getRequest()->getContentType(); + $this->filterInputsHelper(); + $i = $this->getInputs(); - if ($c === null) { - $c = 'NOT_SET'; - } - $this->contentTypeNotSupported($c); + if (!($i instanceof Json)) { + $this->_processNonJson($this->filter->getParameters()); + } else { + $this->_processJson($this->filter->getParameters()); + } } } /** @@ -717,10 +756,6 @@ public function sendResponse(string $message, int $code = 200, string $type = '' $this->response->send(); } } - - public function setResponse(Response $response) { - $this->response = $response; - } /** * Sends a response message to indicate that web service is not implemented. * @@ -834,6 +869,10 @@ public function setRequest(Request $request) : WebServicesManager { return $this; } + + public function setResponse(Response $response) { + $this->response = $response; + } /** * Sets version number of the set. * @@ -1123,6 +1162,33 @@ private function getAction() { return $retVal; } + /** + * Returns the content types declared by #[Consumes] on the service's target method. + * + * @param WebService $service The service to inspect. + * + * @return array|null The array of content types from #[Consumes], or null if not present. + */ + private function getConsumesTypes(WebService $service): ?array { + $targetMethod = $service->getTargetMethod(); + + if ($targetMethod === null) { + return null; + } + + try { + $reflection = new \ReflectionMethod($service, $targetMethod); + $attrs = $reflection->getAttributes(Annotations\Consumes::class); + + if (empty($attrs)) { + return null; + } + + return $attrs[0]->newInstance()->contentTypes; + } catch (\ReflectionException $e) { + return null; + } + } private function isAuth(WebService $service) { if ($service->isAuthRequired()) { // Check if method has authorization annotations @@ -1158,6 +1224,29 @@ private function isAuth(WebService $service) { return true; } + /** + * Checks if the request content type is a parseable type (form-encoded, multipart, or JSON). + * + * When #[Consumes] allows a non-parseable type (e.g. application/octet-stream), + * parameter filtering should be skipped. + * + * @return bool True if the content type is one that the framework can parse parameters from. + */ + private function isParseableContentType(): bool { + $c = $this->getRequest()->getContentType(); + + if ($c === null) { + return false; + } + + foreach (self::POST_CONTENT_TYPES as $parseableType) { + if (strpos($c, $parseableType) === 0) { + return true; + } + } + + return false; + } /** * @deprecated Since 5.1.0. PUT/PATCH body parsing is now handled by Request::parsePutPatchBody(). */ diff --git a/examples/01-core/06-allowed-values-and-pattern/OrderService.php b/examples/01-core/06-allowed-values-and-pattern/OrderService.php index 4d9f0fb..81947aa 100644 --- a/examples/01-core/06-allowed-values-and-pattern/OrderService.php +++ b/examples/01-core/06-allowed-values-and-pattern/OrderService.php @@ -16,29 +16,6 @@ */ #[RestController('orders', 'Order management with enum and pattern validation')] class OrderService extends WebService { - - /** - * Get orders filtered by status. - * The status parameter only accepts specific values. - */ - #[GetMapping] - #[ResponseBody] - #[AllowAnonymous] - #[RequestParam('status', ParamType::STRING, allowedValues: ['pending', 'shipped', 'delivered', 'cancelled'])] - #[RequestParam('sort', ParamType::STRING, true, 'date', allowedValues: ['date', 'total', 'status'])] - public function getOrders(string $status, string $sort = 'date'): array { - return [ - 'filters' => [ - 'status' => $status, - 'sort' => $sort, - ], - 'orders' => [ - ['id' => 1, 'status' => $status, 'total' => 29.99], - ['id' => 2, 'status' => $status, 'total' => 59.99], - ] - ]; - } - /** * Create a new order. * Phone must match international format, postal code must be 5 digits. @@ -62,6 +39,28 @@ public function createOrder(string $name, string $phone, string $postalCode, str ]; } + /** + * Get orders filtered by status. + * The status parameter only accepts specific values. + */ + #[GetMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('status', ParamType::STRING, allowedValues: ['pending', 'shipped', 'delivered', 'cancelled'])] + #[RequestParam('sort', ParamType::STRING, true, 'date', allowedValues: ['date', 'total', 'status'])] + public function getOrders(string $status, string $sort = 'date'): array { + return [ + 'filters' => [ + 'status' => $status, + 'sort' => $sort, + ], + 'orders' => [ + ['id' => 1, 'status' => $status, 'total' => 29.99], + ['id' => 2, 'status' => $status, 'total' => 59.99], + ] + ]; + } + public function isAuthorized(): bool { return true; } diff --git a/examples/01-core/07-reusable-parameter-sets/index.php b/examples/01-core/07-reusable-parameter-sets/index.php index 709f34a..c81b9b1 100644 --- a/examples/01-core/07-reusable-parameter-sets/index.php +++ b/examples/01-core/07-reusable-parameter-sets/index.php @@ -9,9 +9,9 @@ use WebFiori\Http\Annotations\ResponseBody; use WebFiori\Http\Annotations\RestController; use WebFiori\Http\Annotations\UseParameterSet; +use WebFiori\Http\ParameterSet; use WebFiori\Http\ParamOption; use WebFiori\Http\ParamType; -use WebFiori\Http\ParameterSet; use WebFiori\Http\RequestProcessor; use WebFiori\Http\WebService; @@ -41,6 +41,22 @@ public function getParameters(): array { #[RestController('orders')] class OrderService extends WebService { + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[UseParameterSet(AddressParams::class)] + #[RequestParam('total', ParamType::DOUBLE)] + public function createOrder(string $street, string $city, string $zip, string $country, float $total): array { + return [ + 'message' => 'Order created', + 'address' => compact('street', 'city', 'zip', 'country'), + 'total' => $total, + ]; + } + + public function isAuthorized(): bool { + return true; + } #[GetMapping] #[ResponseBody] @@ -56,22 +72,8 @@ public function listOrders(int $page = 1, int $perPage = 20): array { ] ]; } - - #[PostMapping] - #[ResponseBody] - #[AllowAnonymous] - #[UseParameterSet(AddressParams::class)] - #[RequestParam('total', ParamType::DOUBLE)] - public function createOrder(string $street, string $city, string $zip, string $country, float $total): array { - return [ - 'message' => 'Order created', - 'address' => compact('street', 'city', 'zip', 'country'), - 'total' => $total, - ]; + public function processRequest() { } - - public function isAuthorized(): bool { return true; } - public function processRequest() {} } $processor = new RequestProcessor(); diff --git a/examples/03-annotations/01-rest-controller/TaskService.php b/examples/03-annotations/01-rest-controller/TaskService.php index 8cb7d06..8c230e6 100644 --- a/examples/03-annotations/01-rest-controller/TaskService.php +++ b/examples/03-annotations/01-rest-controller/TaskService.php @@ -22,28 +22,12 @@ #[RestController('tasks', 'Task management service')] #[AllowAnonymous] class TaskService extends WebService { - private array $tasks = [ 1 => ['id' => 1, 'name' => 'Write documentation', 'priority' => 'high'], 2 => ['id' => 2, 'name' => 'Fix bugs', 'priority' => 'medium'], 3 => ['id' => 3, 'name' => 'Add tests', 'priority' => 'low'], ]; - #[GetMapping] - #[ResponseBody] - #[RequestParam('task-id', ParamType::INT, true)] - public function getTask(?int $id): ResponseEntity { - if ($id === null) { - return ResponseEntity::ok(new Json(['tasks' => array_values($this->tasks)])); - } - - if (!isset($this->tasks[$id])) { - return ResponseEntity::notFound(new Json(['message' => "Task $id not found"])); - } - - return ResponseEntity::ok(new Json($this->tasks[$id])); - } - #[PostMapping] #[ResponseBody] #[RequestParam('task-name', ParamType::STRING)] @@ -69,4 +53,19 @@ public function deleteTask(int $id): ResponseEntity { return ResponseEntity::noContent(); } + + #[GetMapping] + #[ResponseBody] + #[RequestParam('task-id', ParamType::INT, true)] + public function getTask(?int $id): ResponseEntity { + if ($id === null) { + return ResponseEntity::ok(new Json(['tasks' => array_values($this->tasks)])); + } + + if (!isset($this->tasks[$id])) { + return ResponseEntity::notFound(new Json(['message' => "Task $id not found"])); + } + + return ResponseEntity::ok(new Json($this->tasks[$id])); + } } diff --git a/examples/03-annotations/02-allow-empty/NotesService.php b/examples/03-annotations/02-allow-empty/NotesService.php index 41e8ec7..a544185 100644 --- a/examples/03-annotations/02-allow-empty/NotesService.php +++ b/examples/03-annotations/02-allow-empty/NotesService.php @@ -19,7 +19,6 @@ #[RestController('notes', 'Notes service demonstrating allowEmpty')] #[AllowAnonymous] class NotesService extends WebService { - #[PostMapping] #[ResponseBody] #[RequestParam(name: 'title', type: ParamType::STRING)] diff --git a/examples/03-annotations/03-consumes/FileUploadService.php b/examples/03-annotations/03-consumes/FileUploadService.php new file mode 100644 index 0000000..e465f6d --- /dev/null +++ b/examples/03-annotations/03-consumes/FileUploadService.php @@ -0,0 +1,79 @@ + 'Empty body', + ])); + } + + return ResponseEntity::created(new Json([ + 'message' => 'File uploaded', + 'size' => $size, + 'md5' => md5($body), + ])); + } + + /** + * Upload XML data. + * + * Accepts both application/xml and text/xml. Demonstrates multiple + * content types on a single method. + */ + #[PutMapping] + #[Consumes(MediaType::XML, 'text/xml')] + #[ResponseBody] + public function uploadXml(): ResponseEntity { + $body = file_get_contents('php://input'); + + libxml_use_internal_errors(true); + $xml = simplexml_load_string($body); + + if ($xml === false) { + return ResponseEntity::badRequest(new Json([ + 'message' => 'Invalid XML', + ])); + } + + return ResponseEntity::ok(new Json([ + 'message' => 'XML received', + 'root_element' => $xml->getName(), + ])); + } +} diff --git a/examples/03-annotations/03-consumes/README.md b/examples/03-annotations/03-consumes/README.md new file mode 100644 index 0000000..e755035 --- /dev/null +++ b/examples/03-annotations/03-consumes/README.md @@ -0,0 +1,91 @@ +# Content Type Control with #[Consumes] + +Demonstrates the `#[Consumes]` annotation for per-method content type control, allowing services to accept non-standard content types like `application/octet-stream` or `application/xml`. + +## What This Example Demonstrates + +- `#[Consumes]` to declare accepted request content types per method +- Overriding the default allowed types (form-urlencoded, multipart, JSON) +- Accepting raw binary uploads without parameter filtering +- Accepting multiple content types on a single method +- Reading the raw request body via `php://input` + +## Files + +- [`FileUploadService.php`](FileUploadService.php) - Service with binary and XML upload endpoints +- [`index.php`](index.php) - Application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# Upload a binary file +curl -X POST "http://localhost:8080?service=files" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @somefile.bin + +# Upload from stdin +echo "hello binary world" | curl -X POST "http://localhost:8080?service=files" \ + -H "Content-Type: application/octet-stream" \ + --data-binary @- + +# Upload XML (application/xml) +curl -X PUT "http://localhost:8080?service=files" \ + -H "Content-Type: application/xml" \ + -d 'Hello' + +# Upload XML (text/xml) +curl -X PUT "http://localhost:8080?service=files" \ + -H "Content-Type: text/xml" \ + -d 'true' + +# Rejected: form-urlencoded is NOT listed in #[Consumes] +curl -X POST "http://localhost:8080?service=files" \ + -d "name=test" +# Returns 415 Unsupported Media Type +``` + +## How It Works + +### Default Behavior (No #[Consumes]) + +Without `#[Consumes]`, the framework only allows these content types for POST/PUT: +- `application/x-www-form-urlencoded` +- `multipart/form-data` +- `application/json` + +Any other type gets a 415 response. + +### With #[Consumes] + +The annotation **overrides** the defaults for that specific method: + +```php +#[PostMapping] +#[Consumes(MediaType::OCTET_STREAM)] +public function uploadBinary(): ResponseEntity { + // Only application/octet-stream is accepted + // form-urlencoded and JSON are REJECTED (not in the list) + $body = file_get_contents('php://input'); + // ... +} +``` + +### Parameter Filtering + +When the content type is non-standard (not form-encoded, multipart, or JSON), parameter filtering is **automatically skipped**. The raw body is available via `php://input`. + +If you include a standard type in `#[Consumes]`, normal filtering applies for that type: + +```php +#[Consumes(MediaType::OCTET_STREAM, MediaType::FORM)] +public function flexible(): ResponseEntity { + // With octet-stream: no filtering, read php://input + // With form-urlencoded: normal parameter filtering applies +} +``` diff --git a/examples/03-annotations/03-consumes/index.php b/examples/03-annotations/03-consumes/index.php new file mode 100644 index 0000000..9f4e6d0 --- /dev/null +++ b/examples/03-annotations/03-consumes/index.php @@ -0,0 +1,10 @@ +process(new FileUploadService(), Request::createFromGlobals()); diff --git a/examples/04-advanced/02-openapi-docs/index.php b/examples/04-advanced/02-openapi-docs/index.php index 2dfdbf8..3320f52 100644 --- a/examples/04-advanced/02-openapi-docs/index.php +++ b/examples/04-advanced/02-openapi-docs/index.php @@ -2,29 +2,21 @@ require_once '../../../vendor/autoload.php'; -use WebFiori\Http\OpenAPI\OpenAPIGenerator; -use WebFiori\Http\WebService; -use WebFiori\Http\Annotations\RestController; +use WebFiori\Http\Annotations\AllowAnonymous; use WebFiori\Http\Annotations\GetMapping; use WebFiori\Http\Annotations\PostMapping; use WebFiori\Http\Annotations\RequestParam; use WebFiori\Http\Annotations\ResponseBody; -use WebFiori\Http\Annotations\AllowAnonymous; +use WebFiori\Http\Annotations\RestController; +use WebFiori\Http\OpenAPI\OpenAPIGenerator; use WebFiori\Http\ParamType; +use WebFiori\Http\WebService; /** * Example service for OpenAPI generation. */ #[RestController('users', 'User management')] class UserService extends WebService { - #[GetMapping] - #[ResponseBody] - #[AllowAnonymous] - #[RequestParam('id', ParamType::INT, true)] - public function getUser(?int $id): array { - return ['id' => $id ?? 1, 'name' => 'John']; - } - #[PostMapping] #[ResponseBody] #[AllowAnonymous] @@ -33,9 +25,19 @@ public function getUser(?int $id): array { public function createUser(string $name, string $email): array { return ['id' => 2, 'name' => $name, 'email' => $email]; } + #[GetMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('id', ParamType::INT, true)] + public function getUser(?int $id): array { + return ['id' => $id ?? 1, 'name' => 'John']; + } - public function isAuthorized(): bool { return true; } - public function processRequest() {} + public function isAuthorized(): bool { + return true; + } + public function processRequest() { + } } // Generate OpenAPI spec using the standalone generator diff --git a/examples/04-advanced/04-request-processor/index.php b/examples/04-advanced/04-request-processor/index.php index f692c69..8bab21b 100644 --- a/examples/04-advanced/04-request-processor/index.php +++ b/examples/04-advanced/04-request-processor/index.php @@ -19,7 +19,13 @@ class GreetService extends WebService { #[AllowAnonymous] #[RequestParam('name', ParamType::STRING, true)] public function hello(?string $name): array { - return ['message' => 'Hello, ' . ($name ?? 'World') . '!']; + return ['message' => 'Hello, '.($name ?? 'World').'!']; + } + + public function isAuthorized(): bool { + return true; + } + public function processRequest() { } #[PostMapping] @@ -30,9 +36,6 @@ public function hello(?string $name): array { public function sendGreeting(string $to, string $body): array { return ['sent_to' => $to, 'body' => $body, 'timestamp' => time()]; } - - public function isAuthorized(): bool { return true; } - public function processRequest() {} } // Process directly — no WebServicesManager needed diff --git a/examples/04-advanced/05-openapi-namespace-scan/ProductService.php b/examples/04-advanced/05-openapi-namespace-scan/ProductService.php index 38b6c66..e9cf406 100644 --- a/examples/04-advanced/05-openapi-namespace-scan/ProductService.php +++ b/examples/04-advanced/05-openapi-namespace-scan/ProductService.php @@ -18,20 +18,6 @@ */ #[RestController(name: 'products', path: 'shop/products', description: 'Product catalog')] class ProductService extends WebService { - - #[GetMapping] - #[ResponseBody] - #[AllowAnonymous] - #[ApiResponse(status: '200', description: 'List of products or a single product')] - #[ApiResponse(status: '404', description: 'Product not found')] - #[RequestParam('id', ParamType::INT, true)] - public function getProducts(?int $id): array { - if ($id !== null) { - return ['id' => $id, 'name' => 'Widget', 'price' => 9.99]; - } - return ['products' => [['id' => 1, 'name' => 'Widget']]]; - } - #[PostMapping] #[ResponseBody] #[AllowAnonymous] @@ -53,6 +39,23 @@ public function deleteProduct(int $id): array { return ['deleted' => $id]; } - public function isAuthorized(): bool { return true; } - public function processRequest() {} + #[GetMapping] + #[ResponseBody] + #[AllowAnonymous] + #[ApiResponse(status: '200', description: 'List of products or a single product')] + #[ApiResponse(status: '404', description: 'Product not found')] + #[RequestParam('id', ParamType::INT, true)] + public function getProducts(?int $id): array { + if ($id !== null) { + return ['id' => $id, 'name' => 'Widget', 'price' => 9.99]; + } + + return ['products' => [['id' => 1, 'name' => 'Widget']]]; + } + + public function isAuthorized(): bool { + return true; + } + public function processRequest() { + } } diff --git a/examples/04-advanced/05-openapi-namespace-scan/index.php b/examples/04-advanced/05-openapi-namespace-scan/index.php index 4e31f1a..668bc48 100644 --- a/examples/04-advanced/05-openapi-namespace-scan/index.php +++ b/examples/04-advanced/05-openapi-namespace-scan/index.php @@ -1,7 +1,7 @@ 3, + 'name' => $name, + 'price' => $price, + ]; + } #[GetMapping] #[ResponseBody] @@ -32,16 +43,4 @@ public function getItem(?int $id): array { return ['id' => $id, 'name' => 'Widget']; } - - #[PostMapping] - #[ResponseBody] - #[RequestParam('name', ParamType::STRING)] - #[RequestParam('price', ParamType::DOUBLE)] - public function createItem(string $name, float $price): array { - return [ - 'id' => 3, - 'name' => $name, - 'price' => $price, - ]; - } } diff --git a/examples/05-testing/ItemServiceTest.php b/examples/05-testing/ItemServiceTest.php index a7b5c0a..0440fe9 100644 --- a/examples/05-testing/ItemServiceTest.php +++ b/examples/05-testing/ItemServiceTest.php @@ -6,13 +6,18 @@ use WebFiori\Http\Test\ServiceTestCase; class ItemServiceTest extends ServiceTestCase { - - public function testListItems() { - $this->get(new ItemService()) + public function testCreateItem() { + $this->post(new ItemService(), ['name' => 'Doohickey', 'price' => 9.99]) ->assertOk() ->assertJson() - ->assertJsonHas('data') - ->assertBodyContains('items'); + ->assertBodyContains('Doohickey'); + } + + public function testCreateItemMissingParam() { + $this->post(new ItemService(), ['name' => 'Incomplete']) + ->assertError() + ->assertJson() + ->assertBodyContains('price'); } public function testGetSingleItem() { @@ -22,17 +27,11 @@ public function testGetSingleItem() { ->assertBodyContains('Widget'); } - public function testCreateItem() { - $this->post(new ItemService(), ['name' => 'Doohickey', 'price' => 9.99]) + public function testListItems() { + $this->get(new ItemService()) ->assertOk() ->assertJson() - ->assertBodyContains('Doohickey'); - } - - public function testCreateItemMissingParam() { - $this->post(new ItemService(), ['name' => 'Incomplete']) - ->assertError() - ->assertJson() - ->assertBodyContains('price'); + ->assertJsonHas('data') + ->assertBodyContains('items'); } } diff --git a/tests/WebFiori/Tests/Http/ConsumesAnnotationTest.php b/tests/WebFiori/Tests/Http/ConsumesAnnotationTest.php new file mode 100644 index 0000000..50fc414 --- /dev/null +++ b/tests/WebFiori/Tests/Http/ConsumesAnnotationTest.php @@ -0,0 +1,316 @@ +addRequestMethod('POST'); + } + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + public function createItem(): array { + return ['created' => true]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->post($service, ['name' => 'test']) + ->assertOk() + ->assertJson(); + } + + public function testNoConsumesRejectsUnsupportedType() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('no-consumes-reject'); + $this->addRequestMethod('POST'); + } + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + public function createItem(): array { + return ['created' => true]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->post($service, [], null, ['content-type' => 'application/octet-stream']) + ->assertStatus(415); + } + + // ========================================================================= + // #[Consumes] present — custom types allowed + // ========================================================================= + + public function testConsumesAllowsListedType() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-octet'); + $this->addRequestMethod('POST'); + } + #[PostMapping] + #[Consumes(MediaType::OCTET_STREAM)] + #[ResponseBody] + #[AllowAnonymous] + public function uploadFile(): array { + return ['uploaded' => true]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->post($service, [], null, ['content-type' => 'application/octet-stream']) + ->assertOk() + ->assertJson(); + } + + public function testConsumesRejectsUnlistedType() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-reject'); + $this->addRequestMethod('POST'); + } + #[PostMapping] + #[Consumes(MediaType::OCTET_STREAM)] + #[ResponseBody] + #[AllowAnonymous] + public function uploadFile(): array { + return ['uploaded' => true]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->post($service, [], null, ['content-type' => 'text/csv']) + ->assertStatus(415); + } + + public function testConsumesSkipsParameterParsing() { + // When using a non-parseable type, parameters should NOT be filtered. + // The service should still be dispatched and can read raw body. + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-no-parse'); + $this->addRequestMethod('POST'); + } + #[PostMapping] + #[Consumes(MediaType::OCTET_STREAM)] + #[ResponseBody] + #[AllowAnonymous] + public function uploadFile(): array { + // If parameter parsing was skipped, getParamVal should return null + return ['param_value' => $this->getParamVal('name')]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + // Even though we pass 'name' param, it shouldn't be filtered because + // octet-stream is not a parseable type + $this->post($service, ['name' => 'test'], null, ['content-type' => 'application/octet-stream']) + ->assertOk(); + } + + public function testConsumesMultipleTypes() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-multi'); + $this->addRequestMethod('POST'); + } + #[PostMapping] + #[Consumes(MediaType::XML, 'text/xml')] + #[ResponseBody] + #[AllowAnonymous] + public function acceptXml(): array { + return ['accepted' => true]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + // application/xml should work + $this->post($service, [], null, ['content-type' => 'application/xml']) + ->assertOk(); + + // text/xml should also work + $this->post($service, [], null, ['content-type' => 'text/xml']) + ->assertOk(); + + // text/csv should be rejected + $this->post($service, [], null, ['content-type' => 'text/csv']) + ->assertStatus(415); + } + + public function testConsumesWithStandardTypeStillFilters() { + // If #[Consumes] includes form-urlencoded, normal parameter filtering should occur + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-form'); + $this->addRequestMethod('POST'); + $this->addParameter([ + 'name' => 'username', + 'type' => 'string', + 'optional' => false, + ]); + } + #[PostMapping] + #[Consumes(MediaType::FORM)] + #[ResponseBody] + #[AllowAnonymous] + public function createUser(): array { + return ['user' => $this->getParamVal('username')]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + // Missing required param should result in an error response (422 = validation error) + $this->post($service, [], null, ['content-type' => 'application/x-www-form-urlencoded']) + ->assertStatus(422); + } + + public function testConsumesOnGetMethodIsIgnored() { + // GET requests bypass content type checks regardless of annotation + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-get'); + $this->addRequestMethod('GET'); + } + #[GetMapping] + #[Consumes(MediaType::OCTET_STREAM)] + #[ResponseBody] + #[AllowAnonymous] + public function getData(): array { + return ['data' => 'hello']; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->get($service) + ->assertOk() + ->assertJson(); + } + + public function testConsumesWithPutMethod() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-put'); + $this->addRequestMethod('PUT'); + } + #[PutMapping] + #[Consumes(MediaType::OCTET_STREAM)] + #[ResponseBody] + #[AllowAnonymous] + public function replaceFile(): array { + return ['replaced' => true]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->put($service, [], null, ['content-type' => 'application/octet-stream']) + ->assertOk() + ->assertJson(); + } + + public function testConsumesIntegrationWithResponseBody() { + // Full pipeline: Consumes + ResponseBody on same method + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-full'); + $this->addRequestMethod('POST'); + } + #[PostMapping] + #[Consumes(MediaType::OCTET_STREAM, MediaType::FORM)] + #[ResponseBody] + #[AllowAnonymous] + public function upload(): array { + return ['status' => 'received']; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + // octet-stream should work + $this->post($service, [], null, ['content-type' => 'application/octet-stream']) + ->assertOk() + ->assertJson(); + + // form-urlencoded should also work + $this->post($service, ['foo' => 'bar'], null, ['content-type' => 'application/x-www-form-urlencoded']) + ->assertOk() + ->assertJson(); + } + + public function testConsumesWithContentTypeCharset() { + // Content-Type: application/x-www-form-urlencoded; charset=utf-8 should still match + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-charset'); + $this->addRequestMethod('POST'); + } + #[PostMapping] + #[Consumes(MediaType::FORM)] + #[ResponseBody] + #[AllowAnonymous] + public function acceptForm(): array { + return ['ok' => true]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->post($service, ['x' => '1'], null, ['content-type' => 'application/x-www-form-urlencoded; charset=utf-8']) + ->assertOk(); + } + + public function testConsumesOverridesDefaultTypes() { + // If #[Consumes] only lists octet-stream, the default types (form, json) + // should be REJECTED + $service = new class extends WebService { + public function __construct() { + parent::__construct('consumes-override'); + $this->addRequestMethod('POST'); + } + #[PostMapping] + #[Consumes(MediaType::OCTET_STREAM)] + #[ResponseBody] + #[AllowAnonymous] + public function binaryOnly(): array { + return ['binary' => true]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + // form-urlencoded should be rejected because #[Consumes] overrides defaults + $this->post($service, ['foo' => 'bar']) + ->assertStatus(415); + } +}