diff --git a/.github/workflows/php81.yaml b/.github/workflows/php81.yaml index 9376e92a..208f9fce 100644 --- a/.github/workflows/php81.yaml +++ b/.github/workflows/php81.yaml @@ -12,19 +12,17 @@ concurrency: jobs: test: name: Run Tests - uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.2.5 with: php-version: '8.1' + phpunit-config: 'tests/phpunit.xml' code-coverage: name: Coverage needs: test - uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.5 with: php-version: '8.1' coverage-file: 'php-8.1-coverage.xml' secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - - diff --git a/.github/workflows/php82.yaml b/.github/workflows/php82.yaml index 9957439b..975d0772 100644 --- a/.github/workflows/php82.yaml +++ b/.github/workflows/php82.yaml @@ -12,20 +12,17 @@ concurrency: jobs: test: name: Run Tests - uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.2.5 with: php-version: '8.2' - phpunit-config: "tests/phpunit10.xml" + phpunit-config: 'tests/phpunit.xml' code-coverage: name: Coverage needs: test - uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.5 with: php-version: '8.2' coverage-file: 'php-8.2-coverage.xml' secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - - diff --git a/.github/workflows/php83.yaml b/.github/workflows/php83.yaml index b3b9bdfd..90ad9554 100644 --- a/.github/workflows/php83.yaml +++ b/.github/workflows/php83.yaml @@ -5,43 +5,24 @@ on: branches: [ main, dev ] pull_request: branches: [ main, dev ] -env: - OPERATING_SYS: ubuntu-latest - PHP_VERSION: 8.3 concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: - test: name: Run Tests - uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.2.5 with: php-version: '8.3' - phpunit-config: 'tests/phpunit10.xml' - + phpunit-config: 'tests/phpunit.xml' code-coverage: name: Coverage needs: test - uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.5 with: php-version: '8.3' coverage-file: 'php-8.3-coverage.xml' secrets: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - code-quality: - name: Code Quality - needs: test - uses: WebFiori/workflows/.github/workflows/quality-sonarcloud.yaml@v1.1 - secrets: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - release-prod: - name: Prepare Production Release Branch / Publish Release - needs: [code-coverage, code-quality] - uses: WebFiori/workflows/.github/workflows/release-php.yaml@v1.1 - with: - branch: 'main' + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/php84.yaml b/.github/workflows/php84.yaml index 534d7794..e83d61ce 100644 --- a/.github/workflows/php84.yaml +++ b/.github/workflows/php84.yaml @@ -12,20 +12,17 @@ concurrency: jobs: test: name: Run Tests - uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.2.5 with: php-version: '8.4' - phpunit-config: "tests/phpunit10.xml" + phpunit-config: 'tests/phpunit.xml' code-coverage: name: Coverage needs: test - uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.5 with: php-version: '8.4' coverage-file: 'php-8.4-coverage.xml' secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - - diff --git a/.github/workflows/php85.yaml b/.github/workflows/php85.yaml index cf98c762..3e46f2d8 100644 --- a/.github/workflows/php85.yaml +++ b/.github/workflows/php85.yaml @@ -12,20 +12,32 @@ concurrency: jobs: test: name: Run Tests - uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/test-php.yaml@v1.2.5 with: php-version: '8.5' - phpunit-config: "tests/phpunit10.xml" + phpunit-config: 'tests/phpunit.xml' code-coverage: name: Coverage needs: test - uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.1 + uses: WebFiori/workflows/.github/workflows/coverage-codecov.yaml@v1.2.5 with: php-version: '8.5' coverage-file: 'php-8.5-coverage.xml' secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - - + + code-quality: + name: Code Quality + needs: test + uses: WebFiori/workflows/.github/workflows/quality-sonarcloud.yaml@v1.2.5 + secrets: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + + release-prod: + name: Prepare Production Release Branch / Publish Release + if: ${{ always() && github.event_name == 'push' && github.ref == 'refs/heads/main' }} + needs: [code-coverage, code-quality] + uses: WebFiori/workflows/.github/workflows/release-php.yaml@v1.2.5 + with: + branch: 'main' diff --git a/README.md b/README.md index a3d94287..c91418ea 100644 --- a/README.md +++ b/README.md @@ -151,9 +151,14 @@ class HelloService extends AbstractWebService { } ``` -Both approaches work with `WebServicesManager`: +Both approaches work with `RequestProcessor` (recommended) or `WebServicesManager`: ```php +// Recommended: process a single service directly +$processor = new RequestProcessor(); +$processor->process(new HelloService()); + +// Legacy: register services in a manager $manager = new WebServicesManager(); $manager->addService(new HelloService()); $manager->process(); @@ -353,6 +358,8 @@ ParamOption::MAX_LENGTH // Maximum length (string types) ParamOption::EMPTY // Allow empty strings ParamOption::FILTER // Custom filter function ParamOption::DESCRIPTION // Parameter description +ParamOption::ALLOWED_VALUES // Restrict to a set of allowed values +ParamOption::PATTERN // Regex pattern for validation ``` ### Custom Validation @@ -404,6 +411,73 @@ public function getData(int $id, ?string $name): array { } ``` +### Reusable Parameter Sets + +Implement the `ParameterSet` interface to group related parameters: + +```php +class PaginationParams implements ParameterSet { + public function getParameters(): array { + return [ + 'page' => [ParamOption::TYPE => ParamType::INT, ParamOption::OPTIONAL => true, ParamOption::DEFAULT => 1], + 'per_page' => [ParamOption::TYPE => ParamType::INT, ParamOption::OPTIONAL => true, ParamOption::DEFAULT => 20], + ]; + } +} +``` + +Use with attributes: + +```php +#[GetMapping] +#[ResponseBody] +#[UseParameterSet(PaginationParams::class)] +public function listItems(int $page = 1, int $perPage = 20): array { ... } +``` + +Or traditionally: + +```php +$this->addParameterSet(new PaginationParams()); +``` + +## Cross-Field Validation + +For validation rules that depend on multiple parameters together, use the `#[Validate]` attribute or override the `validate()` method: + +### Method-Specific Validation (Attribute) + +```php +#[PostMapping] +#[ResponseBody] +#[Validate('validateRegistration')] +#[RequestParam('password', ParamType::STRING)] +#[RequestParam('password_confirm', ParamType::STRING)] +public function register(string $password, string $passwordConfirm): array { ... } + +private function validateRegistration(array $inputs): array { + $errors = []; + if ($inputs['password'] !== $inputs['password_confirm']) { + $errors['password_confirm'] = 'Passwords do not match.'; + } + return $errors; // empty = pass +} +``` + +### Service-Wide Validation (Override) + +```php +public function validate(array $inputs): array { + $errors = []; + if (isset($inputs['end_date']) && $inputs['end_date'] <= $inputs['start_date']) { + $errors['end_date'] = 'End date must be after start date.'; + } + return $errors; +} +``` + +Both run if defined — service-wide first, then method-specific. Errors are merged. If any errors exist, the request returns 422 with the error details. + ## Dynamic Status Codes with ResponseEntity The `ResponseEntity` class allows `#[ResponseBody]` methods to return different HTTP status codes based on runtime logic: @@ -566,6 +640,35 @@ For more examples, check the [examples](examples/) directory in this repository. - [`APIFilter`](https://webfiori.com/docs/webfiori/http/APIFilter) - Input filtering and validation - [`Request`](https://webfiori.com/docs/webfiori/http/Request) - HTTP request utilities - [`Response`](https://webfiori.com/docs/webfiori/http/Response) - HTTP response utilities +- [`ErrorResponse`](https://webfiori.com/docs/webfiori/http/ErrorResponse) - Standardized error response generation +- [`OpenAPIGenerator`](https://webfiori.com/docs/webfiori/http/OpenAPI/OpenAPIGenerator) - Standalone OpenAPI spec generation + +### Content Negotiation + +Use `#[Produces]` to declare what content types a method can return. The framework matches against the client's `Accept` header: + +```php +use WebFiori\Http\Annotations\Produces; +use WebFiori\Http\MediaType; +use WebFiori\Http\ResponseEntity; + +#[GetMapping] +#[ResponseBody] +#[Produces(MediaType::JSON, MediaType::XML)] +public function getUser(int $id): ResponseEntity { + $type = $this->getNegotiatedContentType(); + + if ($type === MediaType::XML) { + return new ResponseEntity('...', 200, MediaType::XML); + } + + return ResponseEntity::ok(new Json(['id' => $id])); +} +``` + +- No `#[Produces]` → always JSON (default, unchanged) +- `Accept` header doesn't match → 406 Not Acceptable +- `Accept: */*` or not set → server's first preference ## Contributing diff --git a/WebFiori/Http/APIFilter.php b/WebFiori/Http/APIFilter.php index d099c148..81e3e77b 100644 --- a/WebFiori/Http/APIFilter.php +++ b/WebFiori/Http/APIFilter.php @@ -168,10 +168,12 @@ public static function filter(APIFilter $apiFilter, array $arr): array { $defaultVal = $def[$paramIdx]->getDefault(); if (isset($arr[$name])) { - if (gettype($arr[$name]) != 'array') { - $toBeFiltered = urldecode($arr[$name]); - } else { + if (gettype($arr[$name]) == 'array') { $toBeFiltered = self::decodeArray($arr[$name]); + } else if (gettype($arr[$name]) == 'boolean') { + $toBeFiltered = $arr[$name]; + } else { + $toBeFiltered = urldecode($arr[$name]); } $retVal[$noFIdx][$name] = $toBeFiltered; @@ -344,6 +346,9 @@ private static function applyBasicFilterOnly($def,$toBeFiltered) { if (gettype($toBeFiltered) == 'array') { return $toBeFiltered; } + if (gettype($toBeFiltered) == 'boolean') { + return $toBeFiltered; + } $toBeFiltered = strip_tags($toBeFiltered); $paramObj = $def['parameter']; @@ -383,6 +388,10 @@ private static function applyBasicFilterOnly($def,$toBeFiltered) { } } + if ($returnVal !== self::INVALID) { + $returnVal = self::checkAllowedAndPattern($returnVal, $paramObj); + } + return $returnVal; } private static function applyCustomFilterFunc($def, $toBeFiltered) { @@ -420,12 +429,13 @@ private function applyJsonBasicFilter(Json $extraClean, $toBeFiltered, $def) { $toBeFiltered = strip_tags($toBeFiltered); } - if ($paramType == $toBeFilteredType || $toBeFilteredType == 'object' && $paramType == ParamType::JSON_OBJ) { + if ($paramType == $toBeFilteredType || $toBeFilteredType == 'object' && $paramType == ParamType::JSON_OBJ + || ($toBeFilteredType == 'string' && in_array($paramType, ParamType::getStringTypes()))) { if ($paramType == ParamType::BOOL) { $extraClean->addBoolean($name, $toBeFiltered); } else if ($paramType == ParamType::DOUBLE || $paramType == ParamType::INT) { $extraClean->addNumber($name, $toBeFiltered); - } else if ($paramType == 'string') { + } else if (in_array($paramType, ParamType::getStringTypes())) { $this->cleanJsonStr($extraClean, $def, $toBeFiltered); } else if ($paramType == ParamType::ARR) { $extraClean->addArray($name, $this->cleanJsonArray($toBeFiltered, true)); @@ -545,6 +555,11 @@ private function cleanJsonStr($extraClean, $def, $toBeFiltered) { if (strlen($cleaned) == 0 && $def['options']['options']['allow-empty'] === false) { $extraClean->add($name, null); + return; + } + + if ($cleaned !== null && self::checkAllowedAndPattern($cleaned, $def['parameter']) === self::INVALID) { + $extraClean->add($name, null); } } private static function decodeArray(array $array) { @@ -965,4 +980,27 @@ 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/APITestCase.php b/WebFiori/Http/APITestCase.php index 12df52b4..462568e4 100644 --- a/WebFiori/Http/APITestCase.php +++ b/WebFiori/Http/APITestCase.php @@ -16,9 +16,8 @@ /** * A helper class which is used to implement test cases for API calls. * - * This class will mimic the process of sending HTTP request to an endpoint and - * store the output temporarily on a file. At second stage, the developer - * can read the output and compare it to an expected output. + * @deprecated Use WebFiori\Http\Test\ServiceTestCase instead for a simpler, + * memory-based testing approach with fluent assertions. * * @author Ibrahim */ @@ -103,16 +102,14 @@ public function callEndpoint(WebServicesManager $manager, string $requestMethod, $this->setupRequest($method, $serviceName, $parameters, $httpHeaders); - $manager->setOutputStream(fopen($this->getOutputFile(), 'w')); + $outFile = tempnam(sys_get_temp_dir(), 'api_test_'); + $manager->setOutputStream(fopen($outFile, 'w')); $manager->setRequest(Request::createFromGlobals()); SecurityContext::setCurrentUser($user); $manager->process(); - $result = $manager->readOutputStream(); - - if (file_exists($this->getOutputFile())) { - unlink($this->getOutputFile()); - } + $result = file_get_contents($outFile); + @unlink($outFile); return $this->formatOutput($result); } diff --git a/WebFiori/Http/Annotations/PatchMapping.php b/WebFiori/Http/Annotations/PatchMapping.php new file mode 100644 index 00000000..d8288911 --- /dev/null +++ b/WebFiori/Http/Annotations/PatchMapping.php @@ -0,0 +1,18 @@ +contentTypes = $contentTypes; + } +} diff --git a/WebFiori/Http/Annotations/RequestParam.php b/WebFiori/Http/Annotations/RequestParam.php index 77070cf5..3cfbe648 100644 --- a/WebFiori/Http/Annotations/RequestParam.php +++ b/WebFiori/Http/Annotations/RequestParam.php @@ -21,7 +21,10 @@ public function __construct( public readonly bool $optional = false, public readonly mixed $default = null, public readonly string $description = '', - public readonly mixed $filter = null + public readonly mixed $filter = null, + public readonly array $allowedValues = [], + public readonly ?string $pattern = null, + public readonly ?string $message = null ) { } } diff --git a/WebFiori/Http/Annotations/UseParameterSet.php b/WebFiori/Http/Annotations/UseParameterSet.php new file mode 100644 index 00000000..0049fec3 --- /dev/null +++ b/WebFiori/Http/Annotations/UseParameterSet.php @@ -0,0 +1,21 @@ +add('message', ResponseMessage::get('415')); + $json->add('type', WebService::E); + $json->add('http-code', 415); + + if (!empty($type)) { + $json->add('more-info', new Json(['request-content-type' => $type])); + } + + return ['json' => $json, 'code' => 415]; + } + /** + * Generates a response for invalid parameters. + * + * @param array $params Array of parameter names that have invalid values. + * + * @return array{json: Json, code: int} The response body and HTTP code. + */ + public static function invalidParams(array $params) : array { + $errors = new Json(); + + foreach ($params as $param) { + if ($param instanceof RequestParameter) { + $name = $param->getName(); + $errors->add($name, $param->getMessage() ?? "Invalid value for parameter '$name'."); + } else { + $errors->add($param, "Invalid value for parameter '$param'."); + } + } + + $json = new Json(); + $json->add('message', 'Validation failed'); + $json->add('type', WebService::E); + $json->add('http-code', 422); + $moreInfo = new Json(); + $moreInfo->add('errors', $errors); + $json->add('more-info', $moreInfo); + + return ['json' => $json, 'code' => 422]; + } + /** + * Generates a 405 method not allowed response. + * + * @return array{json: Json, code: int} The response body and HTTP code. + */ + public static function methodNotAllowed() : array { + $json = new Json(); + $json->add('message', ResponseMessage::get('405')); + $json->add('type', WebService::E); + $json->add('http-code', 405); + + return ['json' => $json, 'code' => 405]; + } + /** + * Generates a response for missing required parameters. + * + * @param array $params Array of parameter names that are missing. + * + * @return array{json: Json, code: int} The response body and HTTP code. + */ + public static function missingParams(array $params) : array { + $errors = new Json(); + + foreach ($params as $param) { + if ($param instanceof RequestParameter) { + $name = $param->getName(); + $errors->add($name, $param->getMessage() ?? "Required parameter '$name' is missing."); + } else { + $errors->add($param, "Required parameter '$param' is missing."); + } + } + + $json = new Json(); + $json->add('message', 'Validation failed'); + $json->add('type', WebService::E); + $json->add('http-code', 422); + $moreInfo = new Json(); + $moreInfo->add('errors', $errors); + $json->add('more-info', $moreInfo); + + return ['json' => $json, 'code' => 422]; + } + /** + * Generates a 404 response for missing service name. + * + * @return array{json: Json, code: int} The response body and HTTP code. + */ + public static function missingServiceName() : array { + $json = new Json(); + $json->add('message', ResponseMessage::get('404-3')); + $json->add('type', WebService::E); + $json->add('http-code', 404); + + return ['json' => $json, 'code' => 404]; + } + /** + * Generates a 404 response for unsupported service. + * + * @return array{json: Json, code: int} The response body and HTTP code. + */ + public static function serviceNotFound() : array { + $json = new Json(); + $json->add('message', ResponseMessage::get('404-5')); + $json->add('type', WebService::E); + $json->add('http-code', 404); + + return ['json' => $json, 'code' => 404]; + } + /** + * Generates a 404 response for unimplemented service. + * + * @return array{json: Json, code: int} The response body and HTTP code. + */ + public static function serviceNotImplemented() : array { + $json = new Json(); + $json->add('message', ResponseMessage::get('404-4')); + $json->add('type', WebService::E); + $json->add('http-code', 404); + + return ['json' => $json, 'code' => 404]; + } + /** + * Generates a 401 unauthorized response. + * + * @param string|null $message Custom denial message. If null, uses default. + * + * @return array{json: Json, code: int} The response body and HTTP code. + */ + public static function unauthorized(?string $message = null) : array { + $msg = $message !== null ? $message : ResponseMessage::get('401'); + $json = new Json(); + $json->add('message', $msg); + $json->add('type', WebService::E); + $json->add('http-code', 401); + + 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. + */ + private static function formatParamNames(array $params) : string { + $val = ''; + $count = count($params); + $i = 0; + + foreach ($params as $paramName) { + if ($i + 1 == $count) { + $val .= '\''.$paramName.'\''; + } else { + $val .= '\''.$paramName.'\', '; + } + $i++; + } + + return $val; + } +} diff --git a/WebFiori/Http/MediaType.php b/WebFiori/Http/MediaType.php new file mode 100644 index 00000000..4d18afb1 --- /dev/null +++ b/WebFiori/Http/MediaType.php @@ -0,0 +1,30 @@ +deprecated; } - /** - * Returns the description. - * - * @return string|null Returns the value, or null if not set. - */ - public function getDescription(): ?string { - return $this->description; - } - /** * Returns the example. * @@ -190,19 +172,6 @@ public function setDeprecated(bool $deprecated): HeaderObj { return $this; } - /** - * Sets the description of the header. - * - * @param string $description A brief description of the header. - * - * @return HeaderObj Returns self for method chaining. - */ - public function setDescription(string $description): HeaderObj { - $this->description = $description; - - return $this; - } - /** * Sets an example of the header's potential value. * @@ -289,37 +258,14 @@ public function setStyle(string $style): HeaderObj { public function toJSON(): Json { $json = new Json(); - if ($this->getDescription() !== null) { - $json->add('description', $this->getDescription()); - } - - if ($this->getRequired()) { - $json->add('required', $this->getRequired()); - } - - if ($this->getDeprecated()) { - $json->add('deprecated', $this->getDeprecated()); - } - - if ($this->getStyle() !== null) { - $json->add('style', $this->getStyle()); - } - - if ($this->getExplode() !== null) { - $json->add('explode', $this->getExplode()); - } - - if ($this->getSchema() !== null) { - $json->add('schema', $this->getSchema()); - } - - if ($this->getExample() !== null) { - $json->add('example', $this->getExample()); - } - - if ($this->getExamples() !== null) { - $json->add('examples', $this->getExamples()); - } + $this->addIfNotNull($json, 'description', $this->getDescription()); + $this->addIfTruthy($json, 'required', $this->getRequired()); + $this->addIfTruthy($json, 'deprecated', $this->getDeprecated()); + $this->addIfNotNull($json, 'style', $this->getStyle()); + $this->addIfNotNull($json, 'explode', $this->getExplode()); + $this->addIfNotNull($json, 'schema', $this->getSchema()); + $this->addIfNotNull($json, 'example', $this->getExample()); + $this->addIfNotNull($json, 'examples', $this->getExamples()); return $json; } diff --git a/WebFiori/Http/OpenAPI/OpenAPIGenerator.php b/WebFiori/Http/OpenAPI/OpenAPIGenerator.php new file mode 100644 index 00000000..5bc9e39e --- /dev/null +++ b/WebFiori/Http/OpenAPI/OpenAPIGenerator.php @@ -0,0 +1,50 @@ +getName(); + $paths->addPath($path, $service->toPathItemObj()); + } + } + + $openapi->setPaths($paths); + + return $openapi; + } +} diff --git a/WebFiori/Http/OpenAPI/OpenAPIObject.php b/WebFiori/Http/OpenAPI/OpenAPIObject.php new file mode 100644 index 00000000..de4f3bc4 --- /dev/null +++ b/WebFiori/Http/OpenAPI/OpenAPIObject.php @@ -0,0 +1,58 @@ +description; + } + + public function setDescription(string $description) : static { + $this->description = $description; + return $this; + } + /** + * Adds a value to a Json object only if it is not null. + * + * @param Json $json The target Json object. + * @param string $key The JSON key. + * @param mixed $value The value to add. + */ + protected function addIfNotNull(Json $json, string $key, mixed $value) : void { + if ($value !== null) { + $json->add($key, $value); + } + } + /** + * Adds a value to a Json object only if it is truthy (non-null, non-false, non-empty). + * + * @param Json $json The target Json object. + * @param string $key The JSON key. + * @param mixed $value The value to add. + */ + protected function addIfTruthy(Json $json, string $key, mixed $value) : void { + if (!empty($value)) { + $json->add($key, $value); + } + } +} diff --git a/WebFiori/Http/OpenAPI/ParameterObj.php b/WebFiori/Http/OpenAPI/ParameterObj.php index 45d6b872..4297d607 100644 --- a/WebFiori/Http/OpenAPI/ParameterObj.php +++ b/WebFiori/Http/OpenAPI/ParameterObj.php @@ -27,7 +27,7 @@ * * @see https://spec.openapis.org/oas/v3.1.0#parameter-object */ -class ParameterObj implements JsonI { +class ParameterObj extends OpenAPIObject implements JsonI { /** * If true, clients MAY pass a zero-length string value in place of parameters * that would otherwise be omitted entirely. @@ -58,16 +58,6 @@ class ParameterObj implements JsonI { */ private bool $deprecated = false; - /** - * A brief description of the parameter. - * - * This could contain examples of use. - * CommonMark syntax MAY be used for rich text representation. - * - * @var string|null - */ - private ?string $description = null; - /** * Example of the parameter's potential value. * @@ -176,15 +166,6 @@ public function getDeprecated(): bool { return $this->deprecated; } - /** - * Returns the description. - * - * @return string|null Returns the value, or null if not set. - */ - public function getDescription(): ?string { - return $this->description; - } - /** * Returns the example. * @@ -325,19 +306,6 @@ public function setDeprecated(bool $deprecated): ParameterObj { return $this; } - /** - * Sets the description of the parameter. - * - * @param string $description A brief description of the parameter. - * - * @return ParameterObj Returns self for method chaining. - */ - public function setDescription(string $description): ParameterObj { - $this->description = $description; - - return $this; - } - /** * Sets an example of the parameter's potential value. * @@ -457,45 +425,16 @@ public function toJSON(): Json { 'in' => $this->getIn() ]); - if ($this->getDescription() !== null) { - $json->add('description', $this->getDescription()); - } - - if ($this->getRequired()) { - $json->add('required', $this->getRequired()); - } - - if ($this->getDeprecated()) { - $json->add('deprecated', $this->getDeprecated()); - } - - if ($this->getAllowEmptyValue()) { - $json->add('allowEmptyValue', $this->getAllowEmptyValue()); - } - - if ($this->getStyle() !== null) { - $json->add('style', $this->getStyle()); - } - - if ($this->getExplode() !== null) { - $json->add('explode', $this->getExplode()); - } - - if ($this->getAllowReserved() !== null) { - $json->add('allowReserved', $this->getAllowReserved()); - } - - if ($this->getSchema() !== null) { - $json->add('schema', $this->getSchema()); - } - - if ($this->getExample() !== null) { - $json->add('example', $this->getExample()); - } - - if ($this->getExamples() !== null) { - $json->add('examples', $this->getExamples()); - } + $this->addIfNotNull($json, 'description', $this->getDescription()); + $this->addIfTruthy($json, 'required', $this->getRequired()); + $this->addIfTruthy($json, 'deprecated', $this->getDeprecated()); + $this->addIfTruthy($json, 'allowEmptyValue', $this->getAllowEmptyValue()); + $this->addIfNotNull($json, 'style', $this->getStyle()); + $this->addIfNotNull($json, 'explode', $this->getExplode()); + $this->addIfNotNull($json, 'allowReserved', $this->getAllowReserved()); + $this->addIfNotNull($json, 'schema', $this->getSchema()); + $this->addIfNotNull($json, 'example', $this->getExample()); + $this->addIfNotNull($json, 'examples', $this->getExamples()); return $json; } diff --git a/WebFiori/Http/OpenAPI/Schema.php b/WebFiori/Http/OpenAPI/Schema.php index d4c06611..8141a343 100644 --- a/WebFiori/Http/OpenAPI/Schema.php +++ b/WebFiori/Http/OpenAPI/Schema.php @@ -81,6 +81,18 @@ public static function fromRequestParameter(RequestParameter $param): self { $schema->default = $param->getDefault(); $schema->description = $param->getDescription(); + if (!empty($param->getAllowedValues())) { + $schema->setEnum($param->getAllowedValues()); + } + + if ($param->getPattern() !== null) { + // Strip PHP regex delimiters for OpenAPI (ECMA-262 format) + $pattern = $param->getPattern(); + $delimiter = $pattern[0]; + $lastPos = strrpos($pattern, $delimiter); + $schema->setPattern(substr($pattern, 1, $lastPos - 1)); + } + return $schema; } diff --git a/WebFiori/Http/ParamOption.php b/WebFiori/Http/ParamOption.php index 33b33ca9..3416f4b2 100644 --- a/WebFiori/Http/ParamOption.php +++ b/WebFiori/Http/ParamOption.php @@ -67,4 +67,16 @@ class ParamOption { * 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. + */ + const MESSAGE = 'message'; } diff --git a/WebFiori/Http/ParameterSet.php b/WebFiori/Http/ParameterSet.php new file mode 100644 index 00000000..9930fe9b --- /dev/null +++ b/WebFiori/Http/ParameterSet.php @@ -0,0 +1,31 @@ + Parameter definitions keyed by name. + */ + public function getParameters(): array; +} diff --git a/WebFiori/Http/Request.php b/WebFiori/Http/Request.php index 747346c3..6e30d9c7 100644 --- a/WebFiori/Http/Request.php +++ b/WebFiori/Http/Request.php @@ -31,6 +31,12 @@ public static function createFromGlobals() : Request { $request->setRequestMethod($request->getMethodFromGlobals()); $request->setBody(file_get_contents('php://input')); + $method = $request->getMethod(); + + if ($method === RequestMethod::PUT || $method === RequestMethod::PATCH) { + $request->parsePutPatchBody(); + } + return $request; } /** @@ -229,6 +235,35 @@ public function getRequestedURI(string $pathToAppend = '') : string { public function getUri() : RequestUri { return new RequestUri($this->getRequestedURI()); } + /** + * Parses PUT/PATCH request bodies into $_POST and $_FILES. + * + * PHP only auto-parses POST bodies. For PUT and PATCH, the raw body + * must be parsed manually for application/x-www-form-urlencoded and + * multipart/form-data content types. + */ + public function parsePutPatchBody() : void { + $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; + $input = $this->getBody(); + + if (empty($input)) { + $input = file_get_contents('php://input'); + } + + if (empty($input)) { + return; + } + + if (strpos($contentType, 'application/x-www-form-urlencoded') === 0) { + parse_str($input, $_POST); + + return; + } + + if (strpos($contentType, 'multipart/form-data') === 0) { + $this->parseMultipartBody($input, $contentType); + } + } private function extractHeaders() { $this->getHeadersPool()->clear(); @@ -312,4 +347,56 @@ private function getRequestHeadersFromServer() : array { return $retVal; } + + private function parseMultipartBody(string $input, string $contentType) : void { + preg_match('/boundary=(.+)$/', $contentType, $matches); + + if (!isset($matches[1])) { + return; + } + + $boundary = '--'.$matches[1]; + $parts = explode($boundary, $input); + + foreach ($parts as $part) { + if (trim($part) === '' || trim($part) === '--') { + continue; + } + + $sections = explode("\r\n\r\n", $part, 2); + + if (count($sections) !== 2) { + continue; + } + + $headers = $sections[0]; + $content = rtrim($sections[1], "\r\n"); + + if (preg_match('/name="([^"]+)"/', $headers, $nameMatch)) { + $fieldName = $nameMatch[1]; + + if (preg_match('/filename="([^"]*)"/', $headers, $fileMatch)) { + $filename = $fileMatch[1]; + $fileType = 'application/octet-stream'; + + if (preg_match('/Content-Type:\s*(.+)/i', $headers, $typeMatch)) { + $fileType = trim($typeMatch[1]); + } + + $tmpFile = tempnam(sys_get_temp_dir(), 'put_upload_'); + file_put_contents($tmpFile, $content); + + $_FILES[$fieldName] = [ + 'name' => $filename, + 'type' => $fileType, + 'tmp_name' => $tmpFile, + 'error' => UPLOAD_ERR_OK, + 'size' => strlen($content) + ]; + } else { + $_POST[$fieldName] = $content; + } + } + } + } } diff --git a/WebFiori/Http/RequestParameter.php b/WebFiori/Http/RequestParameter.php index 4774b1e1..50c490e7 100644 --- a/WebFiori/Http/RequestParameter.php +++ b/WebFiori/Http/RequestParameter.php @@ -124,6 +124,24 @@ class RequestParameter implements JsonI { * */ private $type; + /** + * An array of allowed values for the parameter. + * + * @var array + */ + private $allowedValues; + /** + * A regex pattern for validating string parameters. + * + * @var string|null + */ + private $pattern; + /** + * Custom validation error message. + * + * @var string|null + */ + private $message; /** * Creates new instance of the class. * @@ -167,6 +185,9 @@ public function __construct(string $name, string $type = 'string', bool $isOptio $this->applyBasicFilter = true; $this->isEmptyStrAllowed = false; $this->methods = []; + $this->allowedValues = []; + $this->pattern = null; + $this->message = null; } /** * Returns a string that represents the object. @@ -397,6 +418,60 @@ public function getName() : string { 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. + * + * @return string|null The pattern or null if not set. + */ + 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. + * + * @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. + * + * @param string $message The error message to display when validation fails. + */ + public function setMessage(string $message) : void { + $this->message = $message; + } /** * Checks if we need to apply basic filter or not * before applying custom filter callback. @@ -818,6 +893,18 @@ private static function checkParamAttrs(RequestParameter $param, array $options) if (isset($options[ParamOption::DESCRIPTION])) { $param->setDescription($options[ParamOption::DESCRIPTION]); } + + if (isset($options[ParamOption::ALLOWED_VALUES])) { + $param->setAllowedValues($options[ParamOption::ALLOWED_VALUES]); + } + + if (isset($options[ParamOption::PATTERN])) { + $param->setPattern($options[ParamOption::PATTERN]); + } + + if (isset($options[ParamOption::MESSAGE])) { + $param->setMessage($options[ParamOption::MESSAGE]); + } } private function getSchema() : Json { return Schema::fromRequestParameter($this)->toJson(); diff --git a/WebFiori/Http/RequestProcessor.php b/WebFiori/Http/RequestProcessor.php new file mode 100644 index 00000000..6911d253 --- /dev/null +++ b/WebFiori/Http/RequestProcessor.php @@ -0,0 +1,59 @@ +process(new MyService(), Request::createFromGlobals()); + * ``` + * + * @author Ibrahim + */ +class RequestProcessor { + /** + * Process a request against a specific web service. + * + * The processor runs the full pipeline: + * 1. Content type validation + * 2. HTTP method matching + * 3. Parameter filtering and validation + * 4. Authorization check + * 5. Method invocation + * 6. Response serialization + * + * @param WebService $service The service to process. + * @param Request|null $request The incoming HTTP request. If null, creates from globals. + * @param resource|null $outputStream Optional output stream for testing. + */ + public function process(WebService $service, ?Request $request = null, $outputStream = null) : void { + if ($request === null) { + $request = Request::createFromGlobals(); + } + + $manager = new WebServicesManager($request); + $manager->addService($service); + + if ($outputStream !== null) { + $manager->setOutputStream($outputStream); + } + + $manager->process(); + } +} diff --git a/WebFiori/Http/Test/ServiceTestCase.php b/WebFiori/Http/Test/ServiceTestCase.php new file mode 100644 index 00000000..8e5afe0b --- /dev/null +++ b/WebFiori/Http/Test/ServiceTestCase.php @@ -0,0 +1,152 @@ +get(new ItemService(), ['page' => 1]) + * ->assertOk() + * ->assertJsonHas('items'); + * } + * } + * ``` + * + * @author Ibrahim + */ +class ServiceTestCase extends TestCase { + private array $globalsBackup; + + protected function setUp(): void { + parent::setUp(); + $this->globalsBackup = [ + 'GET' => $_GET, + 'POST' => $_POST, + 'FILES' => $_FILES, + 'SERVER' => $_SERVER, + ]; + } + + protected function tearDown(): void { + $_GET = $this->globalsBackup['GET']; + $_POST = $this->globalsBackup['POST']; + $_FILES = $this->globalsBackup['FILES']; + $_SERVER = $this->globalsBackup['SERVER']; + SecurityContext::clear(); + parent::tearDown(); + } + /** + * Send a request to a service with a specific HTTP method. + * + * @param string $method HTTP method (GET, POST, PUT, PATCH, DELETE). + * @param WebService $service The service to test. + * @param array $params Request parameters. + * @param SecurityPrincipal|null $user Authenticated user, or null for anonymous. + * @param array $headers HTTP headers as key-value pairs. + * + * @return TestResponse + */ + protected function call(string $method, WebService $service, array $params = [], ?SecurityPrincipal $user = null, array $headers = []): TestResponse { + $method = strtoupper($method); + $this->setupGlobals($method, $params, $headers); + + $outFile = tempnam(sys_get_temp_dir(), 'svc_test_'); + $stream = fopen($outFile, 'w'); + SecurityContext::setCurrentUser($user); + $request = Request::createFromGlobals(); + + $processor = new RequestProcessor(); + $processor->process($service, $request, $stream); + + $body = file_get_contents($outFile); + @unlink($outFile); + + return new TestResponse($body); + } + /** + * Send a GET 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); + } + /** + * Send a POST 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); + } + /** + * Send a PUT 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); + } + /** + * Send a PATCH 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); + } + /** + * Send a DELETE 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); + } + + 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); + + foreach ($normalizedHeaders as $name => $value) { + if ($name !== 'content-type') { + $_SERVER['HTTP_' . strtoupper(str_replace('-', '_', $name))] = $value; + } + } + } +} diff --git a/WebFiori/Http/Test/TestResponse.php b/WebFiori/Http/Test/TestResponse.php new file mode 100644 index 00000000..13685ecd --- /dev/null +++ b/WebFiori/Http/Test/TestResponse.php @@ -0,0 +1,144 @@ +body = $body; + $this->json = json_decode($body, true); + } + /** + * Returns the raw response body. + * + * @return string + */ + public function getBody(): string { + return $this->body; + } + /** + * Returns the decoded JSON body, or null if not valid JSON. + * + * @return array|null + */ + public function getJson(): ?array { + return $this->json; + } + /** + * Returns the HTTP status code from the JSON response. + * + * @return int + */ + public function getStatusCode(): int { + return $this->json['http-code'] ?? 200; + } + /** + * Assert the response has a specific HTTP status code. + * + * @return self + */ + public function assertStatus(int $code): self { + Assert::assertEquals($code, $this->getStatusCode(), "Expected status $code, got {$this->getStatusCode()}"); + return $this; + } + /** + * Assert the response is successful (no error status). + * + * @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()}" + ); + return $this; + } + /** + * Assert the response is 401 Unauthorized. + * + * @return self + */ + public function assertUnauthorized(): self { + return $this->assertStatus(401); + } + /** + * Assert the response is 404 Not Found. + * + * @return self + */ + public function assertNotFound(): self { + return $this->assertStatus(404); + } + /** + * Assert the response is 405 Method Not Allowed. + * + * @return self + */ + public function assertMethodNotAllowed(): self { + return $this->assertStatus(405); + } + /** + * Assert the response body is valid JSON. + * + * @return self + */ + public function assertJson(): self { + Assert::assertNotNull($this->json, 'Response body is not valid JSON'); + return $this; + } + /** + * Assert the JSON response contains a specific key. + * + * @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; + } + /** + * Assert a JSON key equals an expected value. + * + * @return self + */ + 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 body contains a substring. + * + * @return self + */ + public function assertBodyContains(string $substring): self { + Assert::assertStringContainsString($substring, $this->body); + return $this; + } + /** + * Assert the response type is 'error'. + * + * @return self + */ + public function assertError(): self { + return $this->assertJsonEquals('type', 'error'); + } +} diff --git a/WebFiori/Http/WebService.php b/WebFiori/Http/WebService.php index 98ec24e2..ff5b9ea7 100644 --- a/WebFiori/Http/WebService.php +++ b/WebFiori/Http/WebService.php @@ -12,6 +12,7 @@ use WebFiori\Http\Annotations\DeleteMapping; use WebFiori\Http\Annotations\GetMapping; +use WebFiori\Http\Annotations\PatchMapping; use WebFiori\Http\Annotations\PostMapping; use WebFiori\Http\Annotations\PutMapping; use WebFiori\Http\Annotations\ResponseBody; @@ -92,6 +93,12 @@ 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. @@ -235,6 +242,14 @@ public function addParameters(array $params) { } } } + /** + * Adds all parameters from a ParameterSet to this service. + * + * @param ParameterSet $set The parameter set to add. + */ + public function addParameterSet(ParameterSet $set) : void { + $this->addParameters($set->getParameters()); + } /** * Adds new request method. * @@ -319,8 +334,8 @@ public function checkMethodAuthorization(): bool { // Check RequiresAuth if ($hasRequiresAuth) { - // First call isAuthorized() - if (!$this->isAuthorized()) { + // Check SecurityContext directly instead of isAuthorized() + if (!SecurityContext::isAuthenticated()) { return false; } @@ -346,6 +361,11 @@ public function checkMethodAuthorization(): bool { return SecurityContext::evaluateExpression($preAuth->expression); } + // If class has #[RequiresAuth], check SecurityContext + if ($this->hasClassLevelRequiresAuth()) { + return SecurityContext::isAuthenticated(); + } + return $this->isAuthorized(); } @@ -664,7 +684,7 @@ public function hasResponseBodyAnnotation(string $methodName): bool { * @return bool True if the user is allowed to perform the action. False otherwise. * */ - public function isAuthorized() : bool { + public function isAuthorized() : string|bool { return false; } /** @@ -680,6 +700,27 @@ public function isAuthorized() : 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. @@ -711,6 +752,21 @@ 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. @@ -727,7 +783,33 @@ public function processWithAutoHandling(): void { return; } + // Content negotiation + $negotiated = $this->negotiateContentType($targetMethod); + + if ($negotiated === null) { + $reflection = new \ReflectionMethod($this, $targetMethod); + $producesAttrs = $reflection->getAttributes(Annotations\Produces::class); + $supported = !empty($producesAttrs) ? $producesAttrs[0]->newInstance()->contentTypes : [MediaType::JSON]; + $result = ErrorResponse::notAcceptable($supported); + $this->getManager()->send('application/json', $result['json'], $result['code']); + + return; + } + + $this->negotiatedContentType = $negotiated; + try { + // Run cross-field validation + $validationErrors = $this->runValidation($targetMethod); + + if (!empty($validationErrors)) { + $this->sendResponse('Validation failed', 422, 'error', new \WebFiori\Json\Json([ + 'errors' => $validationErrors + ])); + + return; + } + // Inject parameters into method call $params = $this->getMethodParameters($targetMethod); $result = $this->$targetMethod(...$params); @@ -1072,6 +1154,7 @@ private function getAnnotatedRequestParams(): array { 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) { @@ -1235,7 +1318,8 @@ private function configureMethodMappings(): void { GetMapping::class => RequestMethod::GET, PostMapping::class => RequestMethod::POST, PutMapping::class => RequestMethod::PUT, - DeleteMapping::class => RequestMethod::DELETE + DeleteMapping::class => RequestMethod::DELETE, + PatchMapping::class => RequestMethod::PATCH ]; foreach ($methodMappings as $annotationClass => $httpMethod) { @@ -1300,10 +1384,155 @@ 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). + */ + 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; + } + /** + * Gets the Accept header value from the current request. + */ + private function getAcceptHeader(): string { + $manager = $this->getManager(); + + if ($manager !== null) { + $accept = $manager->getRequest()->getHeader('accept'); + + return !empty($accept) ? $accept[0] : ''; + } + + 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]. + */ + 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 \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); + + // 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; + } /** * Configure parameters from method RequestParam annotations. */ private function configureParametersFromMethod(\ReflectionMethod $method): void { + // Process #[UseParameterSet] attributes first + $setAttributes = $method->getAttributes(Annotations\UseParameterSet::class); + + foreach ($setAttributes as $setAttr) { + $setAnnotation = $setAttr->newInstance(); + $className = $setAnnotation->class; + + if (class_exists($className)) { + $setInstance = new $className(); + + if ($setInstance instanceof ParameterSet) { + $this->addParameterSet($setInstance); + } + } + } + + // Then process #[RequestParam] attributes $paramAttributes = $method->getAttributes(Annotations\RequestParam::class); foreach ($paramAttributes as $attribute) { @@ -1320,6 +1549,18 @@ private function configureParametersFromMethod(\ReflectionMethod $method): void $options[ParamOption::FILTER] = $param->filter; } + if (!empty($param->allowedValues)) { + $options[ParamOption::ALLOWED_VALUES] = $param->allowedValues; + } + + if ($param->pattern !== null) { + $options[ParamOption::PATTERN] = $param->pattern; + } + + if ($param->message !== null) { + $options[ParamOption::MESSAGE] = $param->message; + } + $this->addParameters([ $param->name => $options ]); @@ -1355,15 +1596,37 @@ private function getMethodParameters(string $methodName): array { $mappedObject = $this->getObject($mapEntity->entityClass, $mapEntity->setters); $params[] = $mappedObject; } else { - // Use #[RequestParam] attributes for positional matching + // Build combined parameter name list: UseParameterSet params first, then RequestParam + $setAttrs = $reflection->getAttributes(Annotations\UseParameterSet::class); + $paramNames = []; + + foreach ($setAttrs as $setAttr) { + $setAnnotation = $setAttr->newInstance(); + $className = $setAnnotation->class; + + if (class_exists($className)) { + $setInstance = new $className(); + + if ($setInstance instanceof ParameterSet) { + foreach (array_keys($setInstance->getParameters()) as $name) { + $paramNames[] = $name; + } + } + } + } + $requestParamAttrs = $reflection->getAttributes(Annotations\RequestParam::class); + + foreach ($requestParamAttrs as $rpAttr) { + $rp = $rpAttr->newInstance(); + $paramNames[] = $rp->name; + } + $methodParams = $reflection->getParameters(); foreach ($methodParams as $index => $param) { - // If a RequestParam attribute exists at this position, use its name - if (isset($requestParamAttrs[$index])) { - $annotation = $requestParamAttrs[$index]->newInstance(); - $value = $this->getParamVal($annotation->name); + if (isset($paramNames[$index])) { + $value = $this->getParamVal($paramNames[$index]); } else { $value = $this->getParamVal($param->getName()); } @@ -1418,7 +1681,8 @@ private function methodHandlesHttpMethod(\ReflectionMethod $method, string $http GetMapping::class => RequestMethod::GET, PostMapping::class => RequestMethod::POST, PutMapping::class => RequestMethod::PUT, - DeleteMapping::class => RequestMethod::DELETE + DeleteMapping::class => RequestMethod::DELETE, + PatchMapping::class => RequestMethod::PATCH ]; foreach ($methodMappings as $annotationClass => $mappedMethod) { diff --git a/WebFiori/Http/WebServicesManager.php b/WebFiori/Http/WebServicesManager.php index 7a4f8db2..a42752dd 100644 --- a/WebFiori/Http/WebServicesManager.php +++ b/WebFiori/Http/WebServicesManager.php @@ -20,9 +20,22 @@ * is used to group related services. For example, if we have creat, read, write and * delete services, they can be added to one instance of this class. * - * When a request is made to the services set, An instance of the class must be created - * and the method WebServicesManager::process() must be called. + * @deprecated Use RequestProcessor for processing individual services directly. * + * Migration: + * ```php + * // Before: + * $manager = new WebServicesManager(); + * $manager->addService(new MyService()); + * $manager->process(); + * + * // After: + * $processor = new RequestProcessor(); + * $processor->process(new MyService()); + * ``` + * + * For OpenAPI generation, use OpenAPI\OpenAPIGenerator. + * For error responses, use ErrorResponse. */ class WebServicesManager implements JsonI { /** @@ -213,10 +226,12 @@ public function autoDiscoverServices(?string $path = null) : WebServicesManager * request header. * */ + /** + * @deprecated Use ErrorResponse::contentTypeNotSupported() instead. + */ public function contentTypeNotSupported(string $cType = '') { - $j = new Json(); - $j->add('request-content-type', $cType); - $this->sendResponse(ResponseMessage::get('415'), 415, WebService::E, $j); + $result = ErrorResponse::contentTypeNotSupported($cType); + $this->send('application/json', $result['json'], $result['code']); } /** * Returns the base path for all services. @@ -388,23 +403,12 @@ public final function getVersion() : string { * In addition to the message, The response will send HTTP code 404 - Not Found. * */ + /** + * @deprecated Use ErrorResponse::invalidParams() instead. + */ public function invParams() { - $val = ''; - $i = 0; - $paramsNamesArr = $this->getInvalidParameters(); - $count = count($paramsNamesArr); - - foreach ($paramsNamesArr as $paramName) { - if ($i + 1 == $count) { - $val .= '\''.$paramName.'\''; - } else { - $val .= '\''.$paramName.'\', '; - } - $i++; - } - $this->sendResponse(ResponseMessage::get('404-1').$val.'.', 404, WebService::E, new Json([ - 'invalid' => $paramsNamesArr - ])); + $result = ErrorResponse::invalidParams($this->getInvalidParameters()); + $this->send('application/json', $result['json'], $result['code']); } /** * Checks if request content type is supported by the service or not (For 'POST' @@ -448,23 +452,12 @@ public final function isContentTypeSupported() : bool { * In addition to the message, The response will send HTTP code 404 - Not Found. * */ + /** + * @deprecated Use ErrorResponse::missingParams() instead. + */ public function missingParams() { - $val = ''; - $paramsNamesArr = $this->getMissingParameters(); - $i = 0; - $count = count($paramsNamesArr); - - foreach ($paramsNamesArr as $paramName) { - if ($i + 1 == $count) { - $val .= '\''.$paramName.'\''; - } else { - $val .= '\''.$paramName.'\', '; - } - $i++; - } - $this->sendResponse(ResponseMessage::get('404-2').$val.'.', 404, WebService::E, new Json([ - 'missing' => $paramsNamesArr - ])); + $result = ErrorResponse::missingParams($this->getMissingParameters()); + $this->send('application/json', $result['json'], $result['code']); } /** * Sends a response message to tell the front-end that the parameter @@ -480,8 +473,12 @@ public function missingParams() { * In addition to the message, The response will send HTTP code 404 - Not Found. * */ + /** + * @deprecated Use ErrorResponse::missingServiceName() instead. + */ public function missingServiceName() { - $this->sendResponse(ResponseMessage::get('404-3'), 404, WebService::E); + $result = ErrorResponse::missingServiceName(); + $this->send('application/json', $result['json'], $result['code']); } /** * Sends a response message to indicate that a user is not authorized call a @@ -497,15 +494,18 @@ public function missingServiceName() { * In addition to the message, The response will send HTTP code 401 - Not Authorized. * */ - public function notAuth() { - $this->sendResponse(ResponseMessage::get('401'), 401, WebService::E); + /** + * @deprecated Use ErrorResponse::unauthorized() instead. + */ + public function notAuth(?string $message = null) { + $result = ErrorResponse::unauthorized($message); + $this->send('application/json', $result['json'], $result['code']); } /** * Process user request. * - * This method must be called after creating any - * new instance of the class in order to process user request. + * @deprecated Use RequestProcessor::process() instead for processing individual services. * * @throws Exception */ @@ -621,8 +621,12 @@ public function removeServices() { * In addition to the message, The response will send HTTP code 405 - Method Not Allowed. * */ + /** + * @deprecated Use ErrorResponse::methodNotAllowed() instead. + */ public function requestMethodNotAllowed() { - $this->sendResponse(ResponseMessage::get('405'), 405, WebService::E); + $result = ErrorResponse::methodNotAllowed(); + $this->send('application/json', $result['json'], $result['code']); } /** * Sends Back a data using specific content type and specific response code. @@ -685,6 +689,7 @@ public function sendHeaders(array $headersArr) { * string, an object... . If null is given, the parameter 'more-info' * will be not included in response. Default is empty string. Default is null. * + * @deprecated Use ErrorResponse or Response directly instead. */ public function sendResponse(string $message, int $code = 200, string $type = '', mixed $otherInfo = '') { $json = new Json(); @@ -729,24 +734,19 @@ public function setResponse(Response $response) { * In addition to the message, The response will send HTTP code 404 - Not Found. * */ + /** + * @deprecated Use ErrorResponse::serviceNotImplemented() instead. + */ public function serviceNotImplemented() { - $this->sendResponse(ResponseMessage::get('404-4'), 404, WebService::E); + $result = ErrorResponse::serviceNotImplemented(); + $this->send('application/json', $result['json'], $result['code']); } /** - * Sends a response message to indicate that called web service is not supported by the API. - * - * This method will send back a JSON string in the following format: - *

- * {
- *   "message":"Action not supported",
- *   "type":"error"
- * } - *

- * In addition to the message, The response will send HTTP code 404 - Not Found. - * + * @deprecated Use ErrorResponse::serviceNotFound() instead. */ public function serviceNotSupported() { - $this->sendResponse(ResponseMessage::get('404-5'), 404, WebService::E); + $result = ErrorResponse::serviceNotFound(); + $this->send('application/json', $result['json'], $result['code']); } /** * Sets the base path for all services in this manager. @@ -900,39 +900,32 @@ public function toJSON() : Json { /** * Converts the services manager to an OpenAPI document. * - * This method generates a complete OpenAPI 3.1.0 specification document - * from the registered services. Each service becomes a path in the document. + * @deprecated Use OpenAPI\OpenAPIGenerator::generate() instead. * * @return OpenAPI\OpenAPIObj The OpenAPI document. */ public function toOpenAPI(): OpenAPI\OpenAPIObj { - $info = new OpenAPI\InfoObj( + $generator = new OpenAPI\OpenAPIGenerator(); + + return $generator->generate( + $this->getServices(), $this->getDescription(), - $this->getVersion() + $this->getVersion(), + $this->getBasePath() ); - - $openapi = new OpenAPI\OpenAPIObj($info); - - $paths = new OpenAPI\PathsObj(); - - foreach ($this->getServices() as $service) { - $path = $this->basePath.'/'.$service->getName(); - $paths->addPath($path, $service->toPathItemObj()); - } - - $openapi->setPaths($paths); - - return $openapi; } private function _AfterParamsCheck($processReq) { if ($processReq) { $service = $this->getServiceByName($this->getCalledServiceName()); - if ($this->isAuth($service)) { + $authResult = $this->isAuth($service); + + if ($authResult === true) { $this->processService($service); } else { - $this->notAuth(); + $reason = is_string($authResult) ? $authResult : null; + $this->notAuth($reason); } } else if (count($this->missingParamsArr) != 0) { $this->missingParams(); @@ -993,12 +986,12 @@ private function _processJson($params) { foreach ($params as $param) { if (!$param->isOptional() && !in_array($param->getName(), $paramsNames)) { - $this->missingParamsArr[] = $param->getName(); + $this->missingParamsArr[] = $param; $processReq = false; } - if ($i->get($param->getName()) === null) { - $this->invParamsArr[] = $param->getName(); + if ($i->get($param->getName()) === null && !$param->isOptional()) { + $this->invParamsArr[] = $param; $processReq = false; } } @@ -1010,12 +1003,12 @@ private function _processNonJson($params) { foreach ($params as $param) { if (!$param->isOptional() && !isset($i[$param->getName()])) { - $this->missingParamsArr[] = $param->getName(); + $this->missingParamsArr[] = $param; $processReq = false; } if (isset($i[$param->getName()]) && $i[$param->getName()] === 'INV') { - $this->invParamsArr[] = $param->getName(); + $this->invParamsArr[] = $param; $processReq = false; } } @@ -1064,10 +1057,6 @@ private function filterInputsHelper() { } else if ($reqMeth == RequestMethod::POST || $reqMeth == RequestMethod::PUT || $reqMeth == RequestMethod::PATCH) { - // Populate PUT/PATCH data before filtering - if ($reqMeth == RequestMethod::PUT || $reqMeth == RequestMethod::PATCH) { - $this->populatePutData($contentType); - } $this->filter->filterPOST(); } } @@ -1126,8 +1115,6 @@ private function getAction() { } else if ($reqMeth == RequestMethod::POST && isset($_POST[$serviceNameIndex])) { $retVal = filter_var($_POST[$serviceNameIndex]); } else if ($reqMeth == RequestMethod::PUT || $reqMeth == RequestMethod::PATCH) { - $this->populatePutData($contentType); - if (isset($_POST[$serviceNameIndex])) { $retVal = filter_var($_POST[$serviceNameIndex]); } @@ -1137,103 +1124,45 @@ private function getAction() { return $retVal; } private function isAuth(WebService $service) { - $isAuth = false; - if ($service->isAuthRequired()) { // Check if method has authorization annotations if ($service->hasMethodAuthorizationAnnotations()) { - // Use annotation-based authorization return $service->checkMethodAuthorization(); } + // If class-level #[RequiresAuth] is present, check SecurityContext + if ($service->hasClassLevelRequiresAuth()) { + return SecurityContext::isAuthenticated(); + } + // Fall back to legacy HTTP-method-specific authorization $isAuthCheck = 'isAuthorized'.$this->getRequest()->getMethod(); if (!method_exists($service, $isAuthCheck)) { - return $service->isAuthorized() === null || $service->isAuthorized(); + $result = $service->isAuthorized(); + } else { + $result = $service->$isAuthCheck(); + } + + if ($result === true || $result === null) { + return true; } - return $service->$isAuthCheck() === null || $service->$isAuthCheck(); + // String means not authorized with a custom reason + if (is_string($result)) { + return $result; + } + + return false; } return true; } + /** + * @deprecated Since 5.1.0. PUT/PATCH body parsing is now handled by Request::parsePutPatchBody(). + */ private function populatePutData(string $contentType) { - $contentType = $_SERVER['CONTENT_TYPE'] ?? ''; - $input = file_get_contents('php://input'); - - if (empty($input)) { - return; - } - - // Handle application/x-www-form-urlencoded - if (strpos($contentType, 'application/x-www-form-urlencoded') === 0) { - parse_str($input, $_POST); - - return; - } - - // Handle multipart/form-data - if (strpos($contentType, 'multipart/form-data') === 0) { - // Extract boundary from content type - preg_match('/boundary=(.+)$/', $contentType, $matches); - - if (!isset($matches[1])) { - return; - } - - $boundary = '--'.$matches[1]; - $parts = explode($boundary, $input); - - foreach ($parts as $part) { - if (trim($part) === '' || trim($part) === '--') { - continue; - } - - // Split headers and content - $sections = explode("\r\n\r\n", $part, 2); - - if (count($sections) !== 2) { - continue; - } - - $headers = $sections[0]; - $content = rtrim($sections[1], "\r\n"); - - // Parse Content-Disposition header - if (preg_match('/name="([^"]+)"/', $headers, $nameMatch)) { - $fieldName = $nameMatch[1]; - - // Check if it's a file upload - if (preg_match('/filename="([^"]*)"/', $headers, $fileMatch)) { - // Handle file upload - $filename = $fileMatch[1]; - - // Extract content type if present - $fileType = 'application/octet-stream'; - - if (preg_match('/Content-Type:\s*(.+)/i', $headers, $typeMatch)) { - $fileType = trim($typeMatch[1]); - } - - // Create temporary file - $tmpFile = tempnam(sys_get_temp_dir(), 'put_upload_'); - file_put_contents($tmpFile, $content); - - $_FILES[$fieldName] = [ - 'name' => $filename, - 'type' => $fileType, - 'tmp_name' => $tmpFile, - 'error' => UPLOAD_ERR_OK, - 'size' => strlen($content) - ]; - } else { - // Regular form field - $_POST[$fieldName] = $content; - } - } - } - } + // No-op: body parsing is now done in Request::createFromGlobals() } private function processService(WebService $service) { // Try auto-processing only if service has ResponseBody methods diff --git a/composer.json b/composer.json index 47f28823..c35fbc34 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,6 @@ }, "scripts": { "test": "phpunit --configuration tests/phpunit.xml", - "test10": "phpunit --configuration tests/phpunit10.xml", "fix-cs": "php-cs-fixer fix --config=php_cs.php.dist" }, "require-dev": { diff --git a/examples/01-core/06-allowed-values-and-pattern/OrderService.php b/examples/01-core/06-allowed-values-and-pattern/OrderService.php new file mode 100644 index 00000000..4d9f0fb8 --- /dev/null +++ b/examples/01-core/06-allowed-values-and-pattern/OrderService.php @@ -0,0 +1,71 @@ + [ + '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. + */ + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('customer_name', ParamType::STRING)] + #[RequestParam('phone', ParamType::STRING, pattern: '/^\+[0-9]{10,15}$/')] + #[RequestParam('postal_code', ParamType::STRING, pattern: '/^[0-9]{5}$/')] + #[RequestParam('country', ParamType::STRING, allowedValues: ['US', 'CA', 'UK', 'DE', 'FR'])] + public function createOrder(string $name, string $phone, string $postalCode, string $country): array { + return [ + 'message' => 'Order created', + 'customer' => [ + 'name' => $name, + 'phone' => $phone, + 'postal_code' => $postalCode, + 'country' => $country, + ] + ]; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } +} diff --git a/examples/01-core/06-allowed-values-and-pattern/README.md b/examples/01-core/06-allowed-values-and-pattern/README.md new file mode 100644 index 00000000..1aae3e16 --- /dev/null +++ b/examples/01-core/06-allowed-values-and-pattern/README.md @@ -0,0 +1,108 @@ +# Allowed Values and Pattern Validation + +Demonstrates restricting parameter values using `allowedValues` (enum) and `pattern` (regex) constraints. + +## What This Example Demonstrates + +- Restricting a parameter to a set of allowed values (enum validation) +- Validating parameter format using regex patterns +- Combining both constraints on different parameters +- Using these features with `#[RequestParam]` attributes + +## Files + +- [`OrderService.php`](OrderService.php) - Service with allowed-values and pattern validation +- [`index.php`](index.php) - Main application entry point + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# Valid status (must be one of: pending, shipped, delivered, cancelled) +curl "http://localhost:8080?service=orders&status=pending" + +# Invalid status — returns validation error +curl "http://localhost:8080?service=orders&status=unknown" + +# Valid sort parameter (optional, defaults to 'date') +curl "http://localhost:8080?service=orders&status=shipped&sort=total" + +# Create order with valid phone (+international format) and postal code (5 digits) +curl -X POST http://localhost:8080 \ + -d "service=orders&customer_name=John&phone=%2B12025551234&postal_code=90210&country=US" + +# Invalid phone format — returns validation error +curl -X POST http://localhost:8080 \ + -d "service=orders&customer_name=John&phone=123&postal_code=90210&country=US" + +# Invalid postal code — returns validation error +curl -X POST http://localhost:8080 \ + -d "service=orders&customer_name=John&phone=%2B12025551234&postal_code=ABC&country=US" + +# Invalid country — returns validation error +curl -X POST http://localhost:8080 \ + -d "service=orders&customer_name=John&phone=%2B12025551234&postal_code=90210&country=JP" +``` + +## Code Explanation + +### Allowed Values (Enum) + +Restrict a parameter to a predefined set of values: + +```php +#[RequestParam('status', ParamType::STRING, allowedValues: ['pending', 'shipped', 'delivered', 'cancelled'])] +``` + +If the value is not in the set, it is treated as invalid. + +### Pattern (Regex) + +Validate a parameter against a regular expression: + +```php +#[RequestParam('phone', ParamType::STRING, pattern: '/^\+[0-9]{10,15}$/')] +#[RequestParam('postal_code', ParamType::STRING, pattern: '/^[0-9]{5}$/')] +``` + +If the value does not match the pattern, it is treated as invalid. + +### Traditional Approach + +These options also work with the array-based parameter definition: + +```php +$this->addParameters([ + 'status' => [ + ParamOption::TYPE => ParamType::STRING, + ParamOption::ALLOWED_VALUES => ['pending', 'shipped', 'delivered', 'cancelled'] + ], + 'phone' => [ + ParamOption::TYPE => ParamType::STRING, + ParamOption::PATTERN => '/^\+[0-9]{10,15}$/' + ] +]); +``` + +### OpenAPI + +Both constraints are automatically included in the generated OpenAPI spec: + +```json +{ + "type": "string", + "enum": ["pending", "shipped", "delivered", "cancelled"] +} +``` + +```json +{ + "type": "string", + "pattern": "^\\+[0-9]{10,15}$" +} +``` diff --git a/examples/01-core/06-allowed-values-and-pattern/index.php b/examples/01-core/06-allowed-values-and-pattern/index.php new file mode 100644 index 00000000..dc460c63 --- /dev/null +++ b/examples/01-core/06-allowed-values-and-pattern/index.php @@ -0,0 +1,10 @@ +addService(new OrderService()); +$manager->process(); diff --git a/examples/01-core/07-reusable-parameter-sets/README.md b/examples/01-core/07-reusable-parameter-sets/README.md new file mode 100644 index 00000000..e1ed701a --- /dev/null +++ b/examples/01-core/07-reusable-parameter-sets/README.md @@ -0,0 +1,82 @@ +# Reusable Parameter Sets + +Demonstrates grouping related parameters into reusable sets that can be shared across services. + +## What This Example Demonstrates + +- Implementing the `ParameterSet` interface +- Using `#[UseParameterSet]` attribute on methods +- Combining parameter sets with explicit `#[RequestParam]` attributes +- Validation (pattern, allowed-values) works through parameter sets + +## Files + +- [`index.php`](index.php) - Defines parameter sets and uses them in a service + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# List orders with pagination (uses PaginationParams set) +curl "http://localhost:8080?page=2&per_page=50" + +# List orders with defaults +curl "http://localhost:8080" + +# Create order (uses AddressParams set + explicit total param) +curl -X POST http://localhost:8080 \ + -d "street=123+Main+St&city=Springfield&zip=12345&country=US&total=99.99" + +# Invalid zip (pattern validation from set) +curl -X POST http://localhost:8080 \ + -d "street=123+Main+St&city=Springfield&zip=ABCDE&country=US&total=99.99" + +# Invalid country (allowed-values validation from set) +curl -X POST http://localhost:8080 \ + -d "street=123+Main+St&city=Springfield&zip=12345&country=JP&total=99.99" +``` + +## Code Explanation + +### Define a Parameter Set + +```php +class PaginationParams implements ParameterSet { + public function getParameters(): array { + return [ + 'page' => [ParamOption::TYPE => ParamType::INT, ParamOption::OPTIONAL => true, ParamOption::DEFAULT => 1], + 'per_page' => [ParamOption::TYPE => ParamType::INT, ParamOption::OPTIONAL => true, ParamOption::DEFAULT => 20], + ]; + } +} +``` + +### Use with Attributes + +```php +#[GetMapping] +#[ResponseBody] +#[UseParameterSet(PaginationParams::class)] +public function listItems(int $page = 1, int $perPage = 20): array { ... } +``` + +### Use Traditionally + +```php +$this->addParameterSet(new PaginationParams()); +``` + +### Combine Sets with Explicit Params + +```php +#[UseParameterSet(AddressParams::class)] +#[RequestParam('total', ParamType::DOUBLE)] +public function createOrder(string $street, string $city, string $zip, string $country, float $total): array { ... } +``` + +Method parameters are matched positionally: set params first, then `#[RequestParam]` params. diff --git a/examples/01-core/07-reusable-parameter-sets/index.php b/examples/01-core/07-reusable-parameter-sets/index.php new file mode 100644 index 00000000..709f34a3 --- /dev/null +++ b/examples/01-core/07-reusable-parameter-sets/index.php @@ -0,0 +1,78 @@ + [ParamOption::TYPE => ParamType::INT, ParamOption::OPTIONAL => true, ParamOption::DEFAULT => 1, ParamOption::MIN => 1], + 'per_page' => [ParamOption::TYPE => ParamType::INT, ParamOption::OPTIONAL => true, ParamOption::DEFAULT => 20, ParamOption::MIN => 1, ParamOption::MAX => 100], + ]; + } +} + +class AddressParams implements ParameterSet { + public function getParameters(): array { + return [ + 'street' => [ParamOption::TYPE => ParamType::STRING], + 'city' => [ParamOption::TYPE => ParamType::STRING], + 'zip' => [ParamOption::TYPE => ParamType::STRING, ParamOption::PATTERN => '/^[0-9]{5}$/'], + 'country' => [ParamOption::TYPE => ParamType::STRING, ParamOption::ALLOWED_VALUES => ['US', 'UK', 'DE']], + ]; + } +} + +// Use parameter sets in services via attributes + +#[RestController('orders')] +class OrderService extends WebService { + + #[GetMapping] + #[ResponseBody] + #[AllowAnonymous] + #[UseParameterSet(PaginationParams::class)] + public function listOrders(int $page = 1, int $perPage = 20): array { + return [ + 'page' => $page, + 'per_page' => $perPage, + 'orders' => [ + ['id' => 1, 'total' => 29.99], + ['id' => 2, 'total' => 59.99], + ] + ]; + } + + #[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; } + public function processRequest() {} +} + +$processor = new RequestProcessor(); +$processor->process(new OrderService()); diff --git a/examples/04-advanced/02-openapi-docs/README.md b/examples/04-advanced/02-openapi-docs/README.md new file mode 100644 index 00000000..81e57255 --- /dev/null +++ b/examples/04-advanced/02-openapi-docs/README.md @@ -0,0 +1,71 @@ +# OpenAPI Documentation Generation + +Demonstrates generating OpenAPI 3.1 specifications using the standalone `OpenAPIGenerator` class. + +## What This Example Demonstrates + +- Generating OpenAPI specs without a `WebServicesManager` +- Using `OpenAPIGenerator` directly with service instances +- Setting API description, version, and base path +- Outputting the spec as JSON + +## Files + +- [`index.php`](index.php) - Generates and outputs an OpenAPI spec + +## How to Run + +```bash +php -S localhost:8080 +# Visit http://localhost:8080 to see the generated OpenAPI JSON +``` + +## Code Explanation + +### Standalone Generator (Recommended) + +```php +use WebFiori\Http\OpenAPI\OpenAPIGenerator; + +$generator = new OpenAPIGenerator(); +$spec = $generator->generate( + [new UserService(), new TaskService()], // Services + 'My API', // Description + '2.0.0', // Version + '/api/v2' // Base path +); + +echo $spec->toJSON(); +``` + +### Legacy Approach (Deprecated) + +```php +$manager = new WebServicesManager(); +$manager->addService(new UserService()); +$manager->setDescription('My API'); +$manager->setVersion('2.0.0'); +$spec = $manager->toOpenAPI(); // @deprecated +``` + +### Output + +The generated spec follows OpenAPI 3.1.0 format: + +```json +{ + "openapi": "3.1.0", + "info": { + "title": "My API", + "version": "2.0.0" + }, + "paths": { + "/api/v2/users": { + "get": { ... }, + "post": { ... } + } + } +} +``` + +Parameters defined with `#[RequestParam]` are automatically included as query parameters (GET) or request body properties (POST/PUT/PATCH). diff --git a/examples/04-advanced/02-openapi-docs/index.php b/examples/04-advanced/02-openapi-docs/index.php new file mode 100644 index 00000000..2dfdbf83 --- /dev/null +++ b/examples/04-advanced/02-openapi-docs/index.php @@ -0,0 +1,52 @@ + $id ?? 1, 'name' => 'John']; + } + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('name', ParamType::STRING)] + #[RequestParam('email', ParamType::EMAIL)] + public function createUser(string $name, string $email): array { + return ['id' => 2, 'name' => $name, 'email' => $email]; + } + + public function isAuthorized(): bool { return true; } + public function processRequest() {} +} + +// Generate OpenAPI spec using the standalone generator +$generator = new OpenAPIGenerator(); +$spec = $generator->generate( + [new UserService()], + 'User Management API', + '1.0.0', + '/api/v1' +); + +// Output as JSON +header('Content-Type: application/json'); +echo $spec->toJSON(); diff --git a/examples/04-advanced/04-request-processor/README.md b/examples/04-advanced/04-request-processor/README.md new file mode 100644 index 00000000..7801389b --- /dev/null +++ b/examples/04-advanced/04-request-processor/README.md @@ -0,0 +1,63 @@ +# RequestProcessor — Standalone Service Processing + +Demonstrates processing a web service directly without a `WebServicesManager`. + +## What This Example Demonstrates + +- Using `RequestProcessor` to process a single service +- No service registry or manager setup required +- Automatic request creation from globals +- Full pipeline: validation, auth, invocation, serialization + +## Files + +- [`index.php`](index.php) - Processes a service directly with RequestProcessor + +## How to Run + +```bash +php -S localhost:8080 +``` + +## Testing + +```bash +# GET request +curl "http://localhost:8080?name=Ibrahim" + +# GET without param (uses default) +curl "http://localhost:8080" + +# POST request +curl -X POST http://localhost:8080 \ + -d "to=Alice&body=Hi there" +``` + +## Code Explanation + +### Before (WebServicesManager) + +```php +$manager = new WebServicesManager(); +$manager->addService(new GreetService()); +$manager->process(); +``` + +### After (RequestProcessor) + +```php +$processor = new RequestProcessor(); +$processor->process(new GreetService()); +``` + +The `RequestProcessor` is ideal when: +- You have a router that already resolved which service to call +- You want to process a single service without registry overhead +- You're building framework integrations that handle routing externally + +### With explicit Request (for testing) + +```php +$processor = new RequestProcessor(); +$processor->process(new GreetService(), $request, $outputStream); +``` diff --git a/examples/04-advanced/04-request-processor/index.php b/examples/04-advanced/04-request-processor/index.php new file mode 100644 index 00000000..f692c699 --- /dev/null +++ b/examples/04-advanced/04-request-processor/index.php @@ -0,0 +1,40 @@ + 'Hello, ' . ($name ?? 'World') . '!']; + } + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('to', ParamType::STRING)] + #[RequestParam('body', ParamType::STRING)] + 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 +$processor = new RequestProcessor(); +$processor->process(new GreetService()); diff --git a/sonar-project.properties b/sonar-project.properties index 26701961..c1f3e650 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -6,6 +6,7 @@ sonar.projectName=http sonar.projectVersion=1.0 sonar.exclusions=examples/**, tests/** +sonar.cpd.exclusions=tests/**, examples/** sonar.php.coverage.reportPaths=clover.xml # Encoding of the source code. Default is default system encoding sonar.sourceEncoding=UTF-8 \ No newline at end of file diff --git a/tests/WebFiori/Tests/Http/APIFilterTest.php b/tests/WebFiori/Tests/Http/APIFilterTest.php index d90209cb..88037f08 100644 --- a/tests/WebFiori/Tests/Http/APIFilterTest.php +++ b/tests/WebFiori/Tests/Http/APIFilterTest.php @@ -1418,6 +1418,37 @@ public function testBooleanParameter() { $this->assertTrue($inputs['active'] === true || $inputs['active'] === 1); unset($_GET['active']); } + + /** + * Regression test for #132: native PHP false is destroyed by strip_tags + * before the boolean type check. + */ + public function testNativeBooleanFalse() { + $filter = new APIFilter(); + $param = new RequestParameter('disabled', 'boolean'); + $filter->addRequestParameter($param); + + $_GET['disabled'] = false; + $filter->filterGET(); + $inputs = $filter->getInputs(); + $this->assertFalse($inputs['disabled']); + unset($_GET['disabled']); + } + + /** + * Verify native PHP true still works. + */ + public function testNativeBooleanTrue() { + $filter = new APIFilter(); + $param = new RequestParameter('enabled', 'boolean'); + $filter->addRequestParameter($param); + + $_GET['enabled'] = true; + $filter->filterGET(); + $inputs = $filter->getInputs(); + $this->assertTrue($inputs['enabled']); + unset($_GET['enabled']); + } public function testOptionalParameterNotProvided() { $filter = new APIFilter(); diff --git a/tests/WebFiori/Tests/Http/AllowedValuesAndPatternTest.php b/tests/WebFiori/Tests/Http/AllowedValuesAndPatternTest.php new file mode 100644 index 00000000..efa64568 --- /dev/null +++ b/tests/WebFiori/Tests/Http/AllowedValuesAndPatternTest.php @@ -0,0 +1,441 @@ +setAllowedValues(['active', 'inactive']); + $this->assertEquals(['active', 'inactive'], $param->getAllowedValues()); + } + + public function testGetAllowedValuesDefaultEmpty() { + $param = new RequestParameter('name', ParamType::STRING); + $this->assertEquals([], $param->getAllowedValues()); + } + + public function testSetPatternValid() { + $param = new RequestParameter('phone', ParamType::STRING); + $this->assertTrue($param->setPattern('/^\+[0-9]+$/')); + $this->assertEquals('/^\+[0-9]+$/', $param->getPattern()); + } + + public function testSetPatternInvalid() { + $param = new RequestParameter('phone', ParamType::STRING); + $this->assertFalse($param->setPattern('/invalid[/')); + $this->assertNull($param->getPattern()); + } + + public function testGetPatternDefaultNull() { + $param = new RequestParameter('name', ParamType::STRING); + $this->assertNull($param->getPattern()); + } + + public function testCreateWithAllowedValues() { + $param = RequestParameter::create([ + ParamOption::NAME => 'color', + ParamOption::TYPE => ParamType::STRING, + ParamOption::ALLOWED_VALUES => ['red', 'green', 'blue'] + ]); + $this->assertEquals(['red', 'green', 'blue'], $param->getAllowedValues()); + } + + public function testCreateWithPattern() { + $param = RequestParameter::create([ + ParamOption::NAME => 'zip', + ParamOption::TYPE => ParamType::STRING, + ParamOption::PATTERN => '/^[0-9]{5}$/' + ]); + $this->assertEquals('/^[0-9]{5}$/', $param->getPattern()); + } + + // ========================================================================= + // APIFilter — allowed-values (GET/form-encoded) + // ========================================================================= + + public function testAllowedValuesAccepted() { + $filter = new APIFilter(); + $param = new RequestParameter('status', ParamType::STRING); + $param->setAllowedValues(['active', 'inactive', 'pending']); + $filter->addRequestParameter($param); + + $_GET = ['status' => 'active']; + $filter->filterGET(); + $this->assertEquals('active', $filter->getInputs()['status']); + } + + public function testAllowedValuesRejected() { + $filter = new APIFilter(); + $param = new RequestParameter('status', ParamType::STRING); + $param->setAllowedValues(['active', 'inactive', 'pending']); + $filter->addRequestParameter($param); + + $_GET = ['status' => 'deleted']; + $filter->filterGET(); + $this->assertEquals(APIFilter::INVALID, $filter->getInputs()['status']); + } + + public function testAllowedValuesOnIntParam() { + $filter = new APIFilter(); + $param = new RequestParameter('priority', ParamType::INT); + $param->setAllowedValues([1, 2, 3]); + $filter->addRequestParameter($param); + + $_GET = ['priority' => '2']; + $filter->filterGET(); + $this->assertEquals(2, $filter->getInputs()['priority']); + } + + public function testAllowedValuesOnIntParamRejected() { + $filter = new APIFilter(); + $param = new RequestParameter('priority', ParamType::INT); + $param->setAllowedValues([1, 2, 3]); + $filter->addRequestParameter($param); + + $_GET = ['priority' => '5']; + $filter->filterGET(); + $this->assertEquals(APIFilter::INVALID, $filter->getInputs()['priority']); + } + + public function testAllowedValuesEmptyArrayNoRestriction() { + $filter = new APIFilter(); + $param = new RequestParameter('name', ParamType::STRING); + $param->setAllowedValues([]); + $filter->addRequestParameter($param); + + $_GET = ['name' => 'anything']; + $filter->filterGET(); + $this->assertEquals('anything', $filter->getInputs()['name']); + } + + // ========================================================================= + // APIFilter — pattern (GET/form-encoded) + // ========================================================================= + + public function testPatternAccepted() { + $filter = new APIFilter(); + $param = new RequestParameter('zip', ParamType::STRING); + $param->setPattern('/^[0-9]{5}$/'); + $filter->addRequestParameter($param); + + $_GET = ['zip' => '12345']; + $filter->filterGET(); + $this->assertEquals('12345', $filter->getInputs()['zip']); + } + + public function testPatternRejected() { + $filter = new APIFilter(); + $param = new RequestParameter('zip', ParamType::STRING); + $param->setPattern('/^[0-9]{5}$/'); + $filter->addRequestParameter($param); + + $_GET = ['zip' => 'abcde']; + $filter->filterGET(); + $this->assertEquals(APIFilter::INVALID, $filter->getInputs()['zip']); + } + + public function testPatternPartialMatchRejected() { + $filter = new APIFilter(); + $param = new RequestParameter('code', ParamType::STRING); + $param->setPattern('/^[A-Z]{3}$/'); + $filter->addRequestParameter($param); + + $_GET = ['code' => 'AB']; + $filter->filterGET(); + $this->assertEquals(APIFilter::INVALID, $filter->getInputs()['code']); + } + + public function testPatternWithNoPatternSetPasses() { + $filter = new APIFilter(); + $param = new RequestParameter('name', ParamType::STRING); + $filter->addRequestParameter($param); + + $_GET = ['name' => 'anything goes']; + $filter->filterGET(); + $this->assertEquals('anything goes', $filter->getInputs()['name']); + } + + // ========================================================================= + // APIFilter — both constraints together + // ========================================================================= + + public function testBothAllowedValuesAndPatternPass() { + $filter = new APIFilter(); + $param = new RequestParameter('code', ParamType::STRING); + $param->setAllowedValues(['ABC', 'DEF', 'GHI']); + $param->setPattern('/^[A-Z]{3}$/'); + $filter->addRequestParameter($param); + + $_GET = ['code' => 'ABC']; + $filter->filterGET(); + $this->assertEquals('ABC', $filter->getInputs()['code']); + } + + public function testAllowedValuesPassButPatternFails() { + $filter = new APIFilter(); + $param = new RequestParameter('code', ParamType::STRING); + $param->setAllowedValues(['abc', 'def']); + $param->setPattern('/^[A-Z]{3}$/'); + $filter->addRequestParameter($param); + + // 'abc' is in allowed values but doesn't match uppercase pattern + $_GET = ['code' => 'abc']; + $filter->filterGET(); + $this->assertEquals(APIFilter::INVALID, $filter->getInputs()['code']); + } + + public function testPatternPassesButNotInAllowedValues() { + $filter = new APIFilter(); + $param = new RequestParameter('code', ParamType::STRING); + $param->setAllowedValues(['ABC', 'DEF']); + $param->setPattern('/^[A-Z]{3}$/'); + $filter->addRequestParameter($param); + + // 'GHI' matches pattern but not in allowed values + $_GET = ['code' => 'GHI']; + $filter->filterGET(); + $this->assertEquals(APIFilter::INVALID, $filter->getInputs()['code']); + } + + // ========================================================================= + // APIFilter — JSON body path + // ========================================================================= + + public function testAllowedValuesWithJsonBody() { + $filter = new APIFilter(); + $param = new RequestParameter('status', ParamType::STRING); + $param->setAllowedValues(['active', 'inactive']); + $filter->addRequestParameter($param); + + $jsonFile = sys_get_temp_dir() . '/allowed_values_test.json'; + file_put_contents($jsonFile, json_encode(['status' => 'active'])); + + $filter->setInputStream($jsonFile); + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $filter->filterPOST(); + + $inputs = $filter->getInputs(); + $this->assertEquals('active', $inputs->get('status')); + @unlink($jsonFile); + } + + public function testAllowedValuesRejectedWithJsonBody() { + $filter = new APIFilter(); + $param = new RequestParameter('status', ParamType::STRING); + $param->setAllowedValues(['active', 'inactive']); + $filter->addRequestParameter($param); + + $jsonFile = sys_get_temp_dir() . '/allowed_values_test2.json'; + file_put_contents($jsonFile, json_encode(['status' => 'deleted'])); + + $filter->setInputStream($jsonFile); + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $filter->filterPOST(); + + $inputs = $filter->getInputs(); + $this->assertNull($inputs->get('status')); + @unlink($jsonFile); + } + + public function testPatternWithJsonBody() { + $filter = new APIFilter(); + $param = new RequestParameter('phone', ParamType::STRING); + $param->setPattern('/^\+[0-9]{10,15}$/'); + $filter->addRequestParameter($param); + + $jsonFile = sys_get_temp_dir() . '/pattern_test.json'; + file_put_contents($jsonFile, json_encode(['phone' => '+1234567890123'])); + + $filter->setInputStream($jsonFile); + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $filter->filterPOST(); + + $inputs = $filter->getInputs(); + $this->assertEquals('+1234567890123', $inputs->get('phone')); + @unlink($jsonFile); + } + + public function testPatternRejectedWithJsonBody() { + $filter = new APIFilter(); + $param = new RequestParameter('phone', ParamType::STRING); + $param->setPattern('/^\+[0-9]{10,15}$/'); + $filter->addRequestParameter($param); + + $jsonFile = sys_get_temp_dir() . '/pattern_test2.json'; + file_put_contents($jsonFile, json_encode(['phone' => 'not-a-phone'])); + + $filter->setInputStream($jsonFile); + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $filter->filterPOST(); + + $inputs = $filter->getInputs(); + $this->assertNull($inputs->get('phone')); + @unlink($jsonFile); + } + + // ========================================================================= + // Attribute-based service integration tests + // ========================================================================= + + public function testAllowedValuesViaAttribute() { + $manager = new WebServicesManager(); + $manager->addService(new AllowedValuesPatternService()); + + $output = $this->getRequest($manager, 'allowed-values-pattern-service', [ + 'status' => 'active', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('active', $response['status']); + } + + public function testAllowedValuesViaAttributeRejected() { + $manager = new WebServicesManager(); + $manager->addService(new AllowedValuesPatternService()); + + $output = $this->getRequest($manager, 'allowed-values-pattern-service', [ + 'status' => 'deleted', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + } + + public function testPatternViaAttribute() { + $manager = new WebServicesManager(); + $manager->addService(new AllowedValuesPatternService()); + + $output = $this->postRequest($manager, 'allowed-values-pattern-service', [ + 'phone' => '%2B1234567890123', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('+1234567890123', $response['phone']); + } + + public function testPatternViaAttributeRejected() { + $manager = new WebServicesManager(); + $manager->addService(new AllowedValuesPatternService()); + + $output = $this->postRequest($manager, 'allowed-values-pattern-service', [ + 'phone' => 'invalid', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + } + + // ========================================================================= + // OpenAPI Schema tests + // ========================================================================= + + public function testSchemaWithAllowedValues() { + $param = new RequestParameter('status', ParamType::STRING); + $param->setAllowedValues(['active', 'inactive', 'pending']); + + $schema = Schema::fromRequestParameter($param); + $json = $schema->toJSON(); + + $this->assertEquals(['active', 'inactive', 'pending'], $json->get('enum')); + } + + public function testSchemaWithPattern() { + $param = new RequestParameter('zip', ParamType::STRING); + $param->setPattern('/^[0-9]{5}$/'); + + $schema = Schema::fromRequestParameter($param); + $json = $schema->toJSON(); + + // Pattern should be without PHP delimiters + $this->assertEquals('^[0-9]{5}$', $json->get('pattern')); + } + + public function testSchemaWithBothConstraints() { + $param = new RequestParameter('code', ParamType::STRING); + $param->setAllowedValues(['ABC', 'DEF']); + $param->setPattern('/^[A-Z]{3}$/'); + + $schema = Schema::fromRequestParameter($param); + $json = $schema->toJSON(); + + $this->assertEquals(['ABC', 'DEF'], $json->get('enum')); + $this->assertEquals('^[A-Z]{3}$', $json->get('pattern')); + } + + public function testSchemaWithoutConstraintsHasNoEnumOrPattern() { + $param = new RequestParameter('name', ParamType::STRING); + + $schema = Schema::fromRequestParameter($param); + $json = $schema->toJSON(); + + $this->assertFalse($json->hasKey('enum')); + $this->assertFalse($json->hasKey('pattern')); + } + + // ========================================================================= + // Edge cases + // ========================================================================= + + public function testAllowedValuesWithDefaultFallback() { + $filter = new APIFilter(); + $param = new RequestParameter('status', ParamType::STRING); + $param->setAllowedValues(['active', 'inactive']); + $param->setIsOptional(true); + $param->setDefault('active'); + $filter->addRequestParameter($param); + + // Value not in allowed set, but has default + $_GET = ['status' => 'deleted']; + $filter->filterGET(); + // Should fall back to default since INVALID + default = default + $this->assertEquals('active', $filter->getInputs()['status']); + } + + public function testPatternOnEmailType() { + $filter = new APIFilter(); + $param = new RequestParameter('email', ParamType::EMAIL); + $param->setPattern('/@company\.com$/'); + $filter->addRequestParameter($param); + + // Valid email but wrong domain + $_GET = ['email' => 'user@other.com']; + $filter->filterGET(); + $this->assertEquals(APIFilter::INVALID, $filter->getInputs()['email']); + } + + public function testPatternOnEmailTypeAccepted() { + $filter = new APIFilter(); + $param = new RequestParameter('email', ParamType::EMAIL); + $param->setPattern('/@company\.com$/'); + $filter->addRequestParameter($param); + + $_GET = ['email' => 'user@company.com']; + $filter->filterGET(); + $this->assertEquals('user@company.com', $filter->getInputs()['email']); + } +} diff --git a/tests/WebFiori/Tests/Http/AnnotatedParamsProcessRequestTest.php b/tests/WebFiori/Tests/Http/AnnotatedParamsProcessRequestTest.php index c8b1e5fd..7f922625 100644 --- a/tests/WebFiori/Tests/Http/AnnotatedParamsProcessRequestTest.php +++ b/tests/WebFiori/Tests/Http/AnnotatedParamsProcessRequestTest.php @@ -48,9 +48,9 @@ public function testMissingParamsReportedInProcessRequest() { $this->assertIsArray($response); $this->assertEquals('error', $response['type']); // Framework should report missing required params - $this->assertArrayHasKey('missing', $response['more-info']); - $this->assertContains('username', $response['more-info']['missing']); - $this->assertContains('password', $response['more-info']['missing']); + $this->assertArrayHasKey('errors', $response['more-info']); + $this->assertArrayHasKey('username', $response['more-info']['errors']); + $this->assertArrayHasKey('password', $response['more-info']['errors']); } /** @@ -67,7 +67,7 @@ public function testPartialParamsMissing() { $response = json_decode($output, true); $this->assertIsArray($response); $this->assertEquals('error', $response['type']); - $this->assertContains('password', $response['more-info']['missing']); + $this->assertArrayHasKey('password', $response['more-info']['errors']); } /** diff --git a/tests/WebFiori/Tests/Http/ContentNegotiationTest.php b/tests/WebFiori/Tests/Http/ContentNegotiationTest.php new file mode 100644 index 00000000..c3b3eede --- /dev/null +++ b/tests/WebFiori/Tests/Http/ContentNegotiationTest.php @@ -0,0 +1,174 @@ +addRequestMethod('GET'); + } + #[GetMapping] + #[ResponseBody] + #[AllowAnonymous] + public function getData(): array { + return ['format' => 'json']; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->get($service) + ->assertOk() + ->assertJson(); + } + + public function testNoProducesWithAcceptXmlStillReturnsJson() { + // Without #[Produces], no negotiation happens — always JSON + $service = new class extends WebService { + public function __construct() { + parent::__construct('no-produces-xml'); + $this->addRequestMethod('GET'); + } + #[GetMapping] + #[ResponseBody] + #[AllowAnonymous] + public function getData(): array { + return ['format' => 'json']; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->get($service, [], null, ['accept' => 'application/xml']) + ->assertOk() + ->assertJson(); + } + + // ========================================================================= + // #[Produces] with matching Accept + // ========================================================================= + + public function testProducesJsonAcceptJsonWorks() { + $this->get(new ContentNegotiationService(), ['id' => '1'], null, ['accept' => 'application/json']) + ->assertOk() + ->assertJson() + ->assertBodyContains('John'); + } + + public function testProducesXmlAcceptXmlWorks() { + $response = $this->get(new ContentNegotiationService(), ['id' => '1'], null, ['accept' => 'application/xml']); + $this->assertStringContainsString('', $response->getBody()); + $this->assertStringContainsString('John', $response->getBody()); + } + + // ========================================================================= + // #[Produces] with no match → 406 + // ========================================================================= + + public function testProducesJsonXmlAcceptHtmlReturns406() { + $this->get(new ContentNegotiationService(), ['id' => '1'], null, ['accept' => 'text/html']) + ->assertStatus(406) + ->assertBodyContains('Not Acceptable'); + } + + public function testProducesAcceptPlainTextReturns406() { + $this->get(new ContentNegotiationService(), ['id' => '1'], null, ['accept' => 'text/plain']) + ->assertStatus(406); + } + + // ========================================================================= + // Wildcard and priority + // ========================================================================= + + public function testWildcardAcceptUsesServerDefault() { + $this->get(new ContentNegotiationService(), ['id' => '1'], null, ['accept' => '*/*']) + ->assertOk() + ->assertJson(); + } + + public function testApplicationWildcardWorks() { + $this->get(new ContentNegotiationService(), ['id' => '1'], null, ['accept' => 'application/*']) + ->assertOk(); + } + + public function testQValuePriorityRespected() { + // XML has higher priority (q=1.0) than JSON (q=0.5) + $response = $this->get(new ContentNegotiationService(), ['id' => '1'], null, [ + 'accept' => 'application/json;q=0.5, application/xml;q=1.0' + ]); + $this->assertStringContainsString('', $response->getBody()); + } + + public function testQValueJsonHigherReturnsJson() { + $this->get(new ContentNegotiationService(), ['id' => '1'], null, [ + 'accept' => 'application/json;q=1.0, application/xml;q=0.5' + ]) + ->assertOk() + ->assertJson(); + } + + // ========================================================================= + // No Accept header + // ========================================================================= + + public function testNoAcceptHeaderUsesServerDefault() { + $this->get(new ContentNegotiationService(), ['id' => '1']) + ->assertOk() + ->assertJson(); + } + + // ========================================================================= + // getNegotiatedContentType() + // ========================================================================= + + public function testGetNegotiatedContentTypeReturnsXml() { + $response = $this->get(new ContentNegotiationService(), ['id' => '1'], null, ['accept' => 'application/xml']); + // The service uses getNegotiatedContentType() to decide format + $this->assertStringContainsString('getBody()); + } + + // ========================================================================= + // ErrorResponse::notAcceptable + // ========================================================================= + + public function testNotAcceptableResponse() { + $result = ErrorResponse::notAcceptable([MediaType::JSON, MediaType::XML]); + $this->assertEquals(406, $result['code']); + $json = $result['json']; + $this->assertEquals('Not Acceptable', $json->get('message')); + $this->assertEquals(406, $json->get('http-code')); + } + + // ========================================================================= + // MediaType constants + // ========================================================================= + + public function testMediaTypeConstants() { + $this->assertEquals('application/json', MediaType::JSON); + $this->assertEquals('application/xml', MediaType::XML); + $this->assertEquals('text/html', MediaType::HTML); + $this->assertEquals('text/plain', MediaType::PLAIN); + } +} diff --git a/tests/WebFiori/Tests/Http/CrossFieldValidationTest.php b/tests/WebFiori/Tests/Http/CrossFieldValidationTest.php new file mode 100644 index 00000000..b03ed7cb --- /dev/null +++ b/tests/WebFiori/Tests/Http/CrossFieldValidationTest.php @@ -0,0 +1,186 @@ +post(new ValidationHookService(), [ + 'email' => 'user@example.com', + 'password' => 'securepass123', + 'password_confirm' => 'securepass123', + ]) + ->assertOk() + ->assertJsonHas('data'); + } + + public function testServiceWideValidationPasses() { + $this->post(new ServiceWideValidationService(), [ + 'start' => '1', + 'end' => '10', + ]) + ->assertOk() + ->assertJsonHas('data'); + } + + // ========================================================================= + // Method-specific #[Validate] failures + // ========================================================================= + + public function testPasswordMismatchFails() { + $this->post(new ValidationHookService(), [ + 'email' => 'user@example.com', + 'password' => 'securepass123', + 'password_confirm' => 'different', + ]) + ->assertStatus(422) + ->assertError() + ->assertJsonHas('more-info'); + } + + public function testPasswordTooShortFails() { + $this->post(new ValidationHookService(), [ + 'email' => 'user@example.com', + 'password' => 'short', + 'password_confirm' => 'short', + ]) + ->assertStatus(422) + ->assertError(); + } + + // ========================================================================= + // Service-wide validate() failures + // ========================================================================= + + public function testServiceWideValidationRejectsInvalidInput() { + $this->post(new ServiceWideValidationService(), [ + 'start' => '10', + 'end' => '5', + ]) + ->assertStatus(422) + ->assertError(); + } + + public function testServiceWidePlusAddressingRejected() { + $this->post(new ValidationHookService(), [ + 'email' => 'user%2Btag@example.com', + 'password' => 'securepass123', + 'password_confirm' => 'securepass123', + ]) + ->assertStatus(422) + ->assertError(); + } + + // ========================================================================= + // Both validators run — errors merged + // ========================================================================= + + public function testBothValidatorsRunErrorsMerged() { + $this->post(new ValidationHookService(), [ + 'email' => 'user%2Btag@example.com', + 'password' => 'short', + 'password_confirm' => 'different', + ]) + ->assertStatus(422) + ->assertError(); + } + + // ========================================================================= + // Missing validator method + // ========================================================================= + + public function testMissingValidatorMethodThrowsException() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('bad-validator'); + $this->addRequestMethod('POST'); + } + #[\WebFiori\Http\Annotations\PostMapping] + #[\WebFiori\Http\Annotations\ResponseBody] + #[\WebFiori\Http\Annotations\AllowAnonymous] + #[\WebFiori\Http\Annotations\Validate('nonExistentMethod')] + #[\WebFiori\Http\Annotations\RequestParam('name', 'string')] + public function doSomething(string $name): array { + return ['name' => $name]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $response = $this->post($service, ['name' => 'test']); + $response->assertBodyContains('nonExistentMethod') + ->assertBodyContains('error'); + } + + // ========================================================================= + // No validation defined — works normally + // ========================================================================= + + public function testNoValidationDefinedWorksNormally() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('no-validation'); + $this->addRequestMethod('POST'); + } + #[\WebFiori\Http\Annotations\PostMapping] + #[\WebFiori\Http\Annotations\ResponseBody] + #[\WebFiori\Http\Annotations\AllowAnonymous] + #[\WebFiori\Http\Annotations\RequestParam('value', 'string')] + public function store(string $value): array { + return ['stored' => $value]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->post($service, ['value' => 'hello']) + ->assertOk() + ->assertJsonHas('data'); + } + + // ========================================================================= + // Validate returns empty array — passes + // ========================================================================= + + public function testValidateReturnsEmptyArrayPasses() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('empty-validate'); + $this->addRequestMethod('POST'); + } + public function validate(array $inputs): array { + return []; // Always passes + } + #[\WebFiori\Http\Annotations\PostMapping] + #[\WebFiori\Http\Annotations\ResponseBody] + #[\WebFiori\Http\Annotations\AllowAnonymous] + #[\WebFiori\Http\Annotations\RequestParam('x', 'string')] + public function process(string $x): array { + return ['x' => $x]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->post($service, ['x' => 'test']) + ->assertOk() + ->assertJsonHas('data'); + } +} diff --git a/tests/WebFiori/Tests/Http/EmailParamInjectionTest.php b/tests/WebFiori/Tests/Http/EmailParamInjectionTest.php new file mode 100644 index 00000000..3e403021 --- /dev/null +++ b/tests/WebFiori/Tests/Http/EmailParamInjectionTest.php @@ -0,0 +1,98 @@ +addService(new EmailParamInjectionService()); + + $output = $this->postRequest($manager, 'email-param-injection', [ + 'email' => 'user@example.com', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('user@example.com', $response['email']); + } + + /** + * Test with a different valid email to confirm it's not hardcoded. + */ + public function testAnotherEmailValueInjected() { + $manager = new WebServicesManager(); + $manager->addService(new EmailParamInjectionService()); + + $output = $this->postRequest($manager, 'email-param-injection', [ + 'email' => 'admin@webfiori.com', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('admin@webfiori.com', $response['email']); + } + + /** + * Test that EMAIL param injection works correctly with JSON content type. + * This is the scenario described in issue #112. + */ + public function testEmailValueInjectedWithJsonBody() { + $manager = new WebServicesManager(); + $manager->addService(new EmailParamInjectionService()); + + // Write JSON body to a temp file to simulate php://input + $jsonFile = sys_get_temp_dir() . '/email_test_input.json'; + file_put_contents($jsonFile, json_encode([ + 'service' => 'email-param-injection', + 'email' => 'user@example.com', + ])); + + putenv('REQUEST_METHOD=POST'); + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_POST = []; + $_POST['service'] = 'email-param-injection'; + + $manager->setInputStream($jsonFile); + $manager->setOutputStream(fopen($this->getOutputFile(), 'w')); + $manager->setRequest(\WebFiori\Http\Request::createFromGlobals()); + $manager->process(); + + $output = $manager->readOutputStream(); + @unlink($jsonFile); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('user@example.com', $response['email']); + } + + /** + * Test that invalid email is rejected. + */ + public function testInvalidEmailRejected() { + $manager = new WebServicesManager(); + $manager->addService(new EmailParamInjectionService()); + + $output = $this->postRequest($manager, 'email-param-injection', [ + 'email' => 'not-an-email', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + } +} diff --git a/tests/WebFiori/Tests/Http/ErrorResponseTest.php b/tests/WebFiori/Tests/Http/ErrorResponseTest.php new file mode 100644 index 00000000..90a79795 --- /dev/null +++ b/tests/WebFiori/Tests/Http/ErrorResponseTest.php @@ -0,0 +1,132 @@ +assertEquals(422, $result['code']); + $json = $result['json']; + $this->assertEquals('error', $json->get('type')); + $this->assertEquals(422, $json->get('http-code')); + $this->assertEquals('Validation failed', $json->get('message')); + $errors = $json->get('more-info')->get('errors'); + $this->assertStringContainsString('email', $errors->get('email')); + $this->assertStringContainsString('age', $errors->get('age')); + } + + public function testInvalidParamsSingle() { + $result = ErrorResponse::invalidParams(['name']); + + $json = $result['json']; + $errors = $json->get('more-info')->get('errors'); + $this->assertStringContainsString('name', $errors->get('name')); + } + + public function testMissingParams() { + $result = ErrorResponse::missingParams(['username', 'password']); + + $this->assertEquals(422, $result['code']); + $json = $result['json']; + $this->assertEquals('error', $json->get('type')); + $this->assertEquals(422, $json->get('http-code')); + $this->assertEquals('Validation failed', $json->get('message')); + $errors = $json->get('more-info')->get('errors'); + $this->assertStringContainsString('username', $errors->get('username')); + $this->assertStringContainsString('password', $errors->get('password')); + } + + public function testMissingParamsSingle() { + $result = ErrorResponse::missingParams(['token']); + + $json = $result['json']; + $errors = $json->get('more-info')->get('errors'); + $this->assertStringContainsString('token', $errors->get('token')); + } + + public function testUnauthorizedDefault() { + $result = ErrorResponse::unauthorized(); + + $this->assertEquals(401, $result['code']); + $json = $result['json']; + $this->assertEquals('error', $json->get('type')); + $this->assertEquals(401, $json->get('http-code')); + $this->assertNotEmpty($json->get('message')); + } + + public function testUnauthorizedCustomMessage() { + $result = ErrorResponse::unauthorized('You must be a premium member.'); + + $json = $result['json']; + $this->assertEquals('You must be a premium member.', $json->get('message')); + $this->assertEquals(401, $result['code']); + } + + public function testMethodNotAllowed() { + $result = ErrorResponse::methodNotAllowed(); + + $this->assertEquals(405, $result['code']); + $json = $result['json']; + $this->assertEquals('error', $json->get('type')); + $this->assertEquals(405, $json->get('http-code')); + $this->assertNotEmpty($json->get('message')); + } + + public function testServiceNotFound() { + $result = ErrorResponse::serviceNotFound(); + + $this->assertEquals(404, $result['code']); + $json = $result['json']; + $this->assertEquals('error', $json->get('type')); + $this->assertEquals(404, $json->get('http-code')); + $this->assertNotEmpty($json->get('message')); + } + + public function testServiceNotImplemented() { + $result = ErrorResponse::serviceNotImplemented(); + + $this->assertEquals(404, $result['code']); + $json = $result['json']; + $this->assertEquals('error', $json->get('type')); + $this->assertEquals(404, $json->get('http-code')); + $this->assertNotEmpty($json->get('message')); + } + + public function testMissingServiceName() { + $result = ErrorResponse::missingServiceName(); + + $this->assertEquals(404, $result['code']); + $json = $result['json']; + $this->assertEquals('error', $json->get('type')); + $this->assertEquals(404, $json->get('http-code')); + $this->assertNotEmpty($json->get('message')); + } + + public function testContentTypeNotSupported() { + $result = ErrorResponse::contentTypeNotSupported('text/xml'); + + $this->assertEquals(415, $result['code']); + $json = $result['json']; + $this->assertEquals('error', $json->get('type')); + $this->assertEquals(415, $json->get('http-code')); + $this->assertNotEmpty($json->get('message')); + $this->assertEquals('text/xml', $json->get('more-info')->get('request-content-type')); + } + + public function testContentTypeNotSupportedEmpty() { + $result = ErrorResponse::contentTypeNotSupported(''); + + $json = $result['json']; + $this->assertEquals(415, $json->get('http-code')); + $this->assertFalse($json->hasKey('more-info')); + } +} diff --git a/tests/WebFiori/Tests/Http/IsAuthorizedStringTest.php b/tests/WebFiori/Tests/Http/IsAuthorizedStringTest.php new file mode 100644 index 00000000..1cab6d3c --- /dev/null +++ b/tests/WebFiori/Tests/Http/IsAuthorizedStringTest.php @@ -0,0 +1,118 @@ +addService(new StringAuthDenialService()); + + $output = $this->getRequest($manager, 'string-auth-denial'); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + $this->assertEquals(401, $response['http-code']); + $this->assertEquals('You must be a premium member to access this resource.', $response['message']); + } + + /** + * Test that returning false still uses the default 401 message. + */ + public function testBoolFalseDenialUsesDefault() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('bool-deny'); + $this->addRequestMethod('GET'); + } + public function isAuthorized(): bool { + return false; + } + public function processRequest() { + $this->sendResponse('Should not reach here', 200, 'success'); + } + }; + + $manager = new WebServicesManager(); + $manager->addService($service); + + $output = $this->getRequest($manager, 'bool-deny'); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + $this->assertEquals(401, $response['http-code']); + $this->assertNotEmpty($response['message']); + } + + /** + * Test that returning true allows the request through. + */ + public function testTrueAllowsAccess() { + // Use a service that returns true from isAuthorized + $manager = new WebServicesManager(); + // NoAuthService returns false, but let's use an inline service + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('auth-pass-test'); + $this->addRequestMethod('GET'); + } + public function isAuthorized(): string|bool { + return true; + } + public function processRequest() { + $this->sendResponse('Access granted', 200, 'success'); + } + }; + + $manager->addService($service); + $output = $this->getRequest($manager, 'auth-pass-test'); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('success', $response['type']); + $this->assertEquals('Access granted', $response['message']); + } + + /** + * Test different denial reasons for different conditions. + */ + public function testMultipleDenialReasons() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('multi-reason'); + $this->addRequestMethod('GET'); + } + public function isAuthorized(): string|bool { + $token = $this->getAuthHeader(); + if ($token === null) { + return 'Authentication token is required.'; + } + return 'Insufficient privileges.'; + } + public function processRequest() { + $this->sendResponse('OK', 200, 'success'); + } + }; + + $manager = new WebServicesManager(); + $manager->addService($service); + + // No auth header — should get "token required" message + $output = $this->getRequest($manager, 'multi-reason'); + $response = json_decode($output, true); + $this->assertEquals('Authentication token is required.', $response['message']); + } +} diff --git a/tests/WebFiori/Tests/Http/OpenAPIGeneratorTest.php b/tests/WebFiori/Tests/Http/OpenAPIGeneratorTest.php new file mode 100644 index 00000000..3101521e --- /dev/null +++ b/tests/WebFiori/Tests/Http/OpenAPIGeneratorTest.php @@ -0,0 +1,88 @@ +generate([$service], 'Test API', '1.0.0'); + $json = $spec->toJSON(); + + $this->assertEquals('3.1.0', $json->get('openapi')); + $this->assertEquals('Test API', $json->get('info')->get('title')); + $this->assertEquals('1.0.0', $json->get('info')->get('version')); + $this->assertTrue($json->get('paths')->hasKey('/' . $service->getName())); + } + + public function testGenerateWithMultipleServices() { + $generator = new OpenAPIGenerator(); + $service1 = new AnnotatedMethodService(); + $service2 = new AllMethodsService(); + + $spec = $generator->generate([$service1, $service2], 'Multi API', '2.0.0'); + $json = $spec->toJSON(); + + $this->assertTrue($json->get('paths')->hasKey('/' . $service1->getName())); + $this->assertTrue($json->get('paths')->hasKey('/' . $service2->getName())); + } + + public function testGenerateWithBasePath() { + $generator = new OpenAPIGenerator(); + $service = new AnnotatedMethodService(); + + $spec = $generator->generate([$service], 'API', '1.0.0', '/api/v2'); + $json = $spec->toJSON(); + + $this->assertTrue($json->get('paths')->hasKey('/api/v2/' . $service->getName())); + } + + public function testGenerateWithDescriptionAndVersion() { + $generator = new OpenAPIGenerator(); + + $spec = $generator->generate([], 'My Great API', '3.5.1'); + $json = $spec->toJSON(); + + $this->assertEquals('My Great API', $json->get('info')->get('title')); + $this->assertEquals('3.5.1', $json->get('info')->get('version')); + } + + public function testGenerateEmptyServices() { + $generator = new OpenAPIGenerator(); + + $spec = $generator->generate([]); + $json = $spec->toJSON(); + + $this->assertEquals('3.1.0', $json->get('openapi')); + // Paths object exists but has no paths + $this->assertNotNull($json->get('paths')); + } + + public function testDeprecatedManagerMethodStillWorks() { + $manager = new WebServicesManager(); + $manager->addService(new AnnotatedMethodService()); + $manager->setDescription('Legacy API'); + $manager->setVersion('1.2.3'); + $manager->setBasePath('/legacy'); + + $spec = $manager->toOpenAPI(); + $json = $spec->toJSON(); + + $this->assertEquals('Legacy API', $json->get('info')->get('title')); + $this->assertEquals('1.2.3', $json->get('info')->get('version')); + $service = new AnnotatedMethodService(); + $this->assertTrue($json->get('paths')->hasKey('/legacy/' . $service->getName())); + } +} diff --git a/tests/WebFiori/Tests/Http/ParameterSetTest.php b/tests/WebFiori/Tests/Http/ParameterSetTest.php new file mode 100644 index 00000000..dbb805c5 --- /dev/null +++ b/tests/WebFiori/Tests/Http/ParameterSetTest.php @@ -0,0 +1,237 @@ +assertInstanceOf(ParameterSet::class, $set); + + $params = $set->getParameters(); + $this->assertArrayHasKey('page', $params); + $this->assertArrayHasKey('per_page', $params); + } + + public function testAddParameterSetTraditional() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('test-set'); + $this->addRequestMethod('GET'); + $this->addParameterSet(new PaginationParams()); + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $this->assertTrue($service->hasParameter('page')); + $this->assertTrue($service->hasParameter('per_page')); + } + + public function testAddMultipleSets() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('multi-set'); + $this->addRequestMethod('POST'); + $this->addParameterSet(new PaginationParams()); + $this->addParameterSet(new AddressParams()); + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + // Pagination params + $this->assertTrue($service->hasParameter('page')); + $this->assertTrue($service->hasParameter('per_page')); + // Address params + $this->assertTrue($service->hasParameter('street')); + $this->assertTrue($service->hasParameter('city')); + $this->assertTrue($service->hasParameter('zip')); + $this->assertTrue($service->hasParameter('country')); + } + + public function testParameterSetPreservesOptions() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('options-test'); + $this->addRequestMethod('GET'); + $this->addParameterSet(new PaginationParams()); + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $pageParam = $service->getParameterByName('page'); + $this->assertNotNull($pageParam); + $this->assertTrue($pageParam->isOptional()); + $this->assertEquals(1, $pageParam->getDefault()); + $this->assertEquals(1, $pageParam->getMinValue()); + } + + public function testParameterSetWithPatternAndAllowedValues() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('validation-test'); + $this->addRequestMethod('GET'); + $this->addParameterSet(new AddressParams()); + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $zipParam = $service->getParameterByName('zip'); + $this->assertNotNull($zipParam); + $this->assertEquals('/^[0-9]{5}$/', $zipParam->getPattern()); + + $countryParam = $service->getParameterByName('country'); + $this->assertNotNull($countryParam); + $this->assertEquals(['US', 'UK', 'DE'], $countryParam->getAllowedValues()); + } + + // ========================================================================= + // #[UseParameterSet] attribute tests + // ========================================================================= + + public function testUseParameterSetAttribute() { + $manager = new WebServicesManager(); + $manager->addService(new ParameterSetService()); + + $output = $this->getRequest($manager, 'parameter-set-service', [ + 'page' => '3', + 'per_page' => '50', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals(3, $response['page']); + $this->assertEquals(50, $response['per_page']); + } + + public function testUseParameterSetWithDefaults() { + $manager = new WebServicesManager(); + $manager->addService(new ParameterSetService()); + + // No params — should use defaults (page=1, per_page=20) + $output = $this->getRequest($manager, 'parameter-set-service'); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals(1, $response['page']); + $this->assertEquals(20, $response['per_page']); + } + + public function testUseParameterSetWithRequestParam() { + $manager = new WebServicesManager(); + $manager->addService(new ParameterSetService()); + + $output = $this->postRequest($manager, 'parameter-set-service', [ + 'street' => '123 Main St', + 'city' => 'Springfield', + 'zip' => '12345', + 'country' => 'US', + 'note' => 'Leave at door', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('123 Main St', $response['street']); + $this->assertEquals('Springfield', $response['city']); + $this->assertEquals('12345', $response['zip']); + $this->assertEquals('US', $response['country']); + $this->assertEquals('Leave at door', $response['note']); + } + + public function testUseParameterSetValidationApplied() { + $manager = new WebServicesManager(); + $manager->addService(new ParameterSetService()); + + // Invalid zip (not 5 digits) and invalid country (not in allowed values) + $output = $this->postRequest($manager, 'parameter-set-service', [ + 'street' => '123 Main St', + 'city' => 'Springfield', + 'zip' => 'ABCDE', + 'country' => 'JP', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + } + + public function testUseParameterSetMissingRequired() { + $manager = new WebServicesManager(); + $manager->addService(new ParameterSetService()); + + // Missing required address fields + $output = $this->postRequest($manager, 'parameter-set-service', [ + 'street' => '123 Main St', + ]); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + } + + public function testInvalidSetClassIgnored() { + // A UseParameterSet pointing to a non-ParameterSet class should be silently ignored + $service = new class extends WebService { + public function __construct() { + parent::__construct('invalid-set'); + $this->addRequestMethod('GET'); + } + public function isAuthorized(): bool { return true; } + public function processRequest() { + $this->sendResponse('ok', 200, 'success'); + } + }; + + // Manually test that configuring with a non-ParameterSet class doesn't crash + $manager = new WebServicesManager(); + $manager->addService($service); + + $output = $this->getRequest($manager, 'invalid-set'); + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('success', $response['type']); + } + + public function testNonExistentClassIgnored() { + // Verify that a non-existent class in UseParameterSet doesn't crash + $service = new class extends WebService { + public function __construct() { + parent::__construct('nonexistent-set'); + $this->addRequestMethod('GET'); + } + public function isAuthorized(): bool { return true; } + public function processRequest() { + $this->sendResponse('ok', 200, 'success'); + } + }; + + $manager = new WebServicesManager(); + $manager->addService($service); + + $output = $this->getRequest($manager, 'nonexistent-set'); + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('success', $response['type']); + } +} diff --git a/tests/WebFiori/Tests/Http/RequestParameterTest.php b/tests/WebFiori/Tests/Http/RequestParameterTest.php index 9af75aab..14efa0e2 100644 --- a/tests/WebFiori/Tests/Http/RequestParameterTest.php +++ b/tests/WebFiori/Tests/Http/RequestParameterTest.php @@ -593,19 +593,17 @@ public function testSetMinLength05() { } /** * @test - * @depends testConstructor00 - * @param RequestParameter $reqParam */ - public function testToJson00($reqParam) { + public function testToJson00() { + $reqParam = new RequestParameter(''); $reqParam->setDescription('Test Parameter.'); $this->assertEquals('{"name":"a-parameter","in":"query","required":true,"description":"Test Parameter.","schema":{"type":"string"}}',$reqParam->toJSON().''); } /** * @test - * @depends testConstructor03 - * @param RequestParameter $reqParam */ - public function testToJson01($reqParam) { + public function testToJson01() { + $reqParam = new RequestParameter('valid','integer',true); $reqParam->setDescription('Test Parameter.'); $this->assertEquals('{"name":"valid","in":"query","required":false,"description":"Test Parameter.","schema":{"type":"integer","minimum":'.~PHP_INT_MAX.',"maximum":'.PHP_INT_MAX.'}}',$reqParam->toJSON().''); } diff --git a/tests/WebFiori/Tests/Http/RequestProcessorTest.php b/tests/WebFiori/Tests/Http/RequestProcessorTest.php new file mode 100644 index 00000000..40a0eca7 --- /dev/null +++ b/tests/WebFiori/Tests/Http/RequestProcessorTest.php @@ -0,0 +1,162 @@ + 'hello']; + $_SERVER['CONTENT_TYPE'] = ''; + + $outFile = $this->getOutputFile(); + $stream = fopen($outFile, 'w'); + $request = Request::createFromGlobals(); + + $processor->process($service, $request, $stream); + + $output = file_get_contents($outFile); + @unlink($outFile); + + $this->assertNotEmpty($output); + } + + /** + * Test processing a POST request with parameter validation. + */ + public function testProcessPostWithParams() { + $processor = new RequestProcessor(); + $service = new AllMethodsService(); + + putenv('REQUEST_METHOD=POST'); + $_POST = ['name' => 'John']; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + + $outFile = $this->getOutputFile(); + $stream = fopen($outFile, 'w'); + $request = Request::createFromGlobals(); + + $processor->process($service, $request, $stream); + + $output = file_get_contents($outFile); + @unlink($outFile); + + $this->assertNotEmpty($output); + } + + /** + * Test that unauthorized service returns 401. + */ + public function testUnauthorizedReturnsError() { + $processor = new RequestProcessor(); + $service = new StringAuthDenialService(); + + putenv('REQUEST_METHOD=GET'); + $_GET = []; + $_SERVER['CONTENT_TYPE'] = ''; + + $outFile = $this->getOutputFile(); + $stream = fopen($outFile, 'w'); + $request = Request::createFromGlobals(); + + $processor->process($service, $request, $stream); + + $output = file_get_contents($outFile); + @unlink($outFile); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + $this->assertEquals(401, $response['http-code']); + $this->assertEquals('You must be a premium member to access this resource.', $response['message']); + } + + /** + * Test that wrong HTTP method returns 405. + */ + public function testMethodNotAllowed() { + $processor = new RequestProcessor(); + $service = new AnnotatedMethodService(); + + putenv('REQUEST_METHOD=DELETE'); + $_GET = []; + $_SERVER['CONTENT_TYPE'] = ''; + + $outFile = $this->getOutputFile(); + $stream = fopen($outFile, 'w'); + $request = Request::createFromGlobals(); + + $processor->process($service, $request, $stream); + + $output = file_get_contents($outFile); + @unlink($outFile); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + } + + /** + * Test that unsupported content type returns 415. + */ + public function testContentTypeNotSupported() { + $processor = new RequestProcessor(); + $service = new AllMethodsService(); + + putenv('REQUEST_METHOD=POST'); + $_POST = []; + $_SERVER['CONTENT_TYPE'] = 'text/xml'; + + $outFile = $this->getOutputFile(); + $stream = fopen($outFile, 'w'); + $request = Request::createFromGlobals(); + + $processor->process($service, $request, $stream); + + $output = file_get_contents($outFile); + @unlink($outFile); + + $response = json_decode($output, true); + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + } + + /** + * Test processing with null request (creates from globals). + */ + public function testProcessWithNullRequest() { + $processor = new RequestProcessor(); + $service = new AnnotatedMethodService(); + + putenv('REQUEST_METHOD=GET'); + $_GET = ['param1' => 'test']; + $_SERVER['CONTENT_TYPE'] = ''; + + $outFile = $this->getOutputFile(); + $stream = fopen($outFile, 'w'); + + $processor->process($service, null, $stream); + + $output = file_get_contents($outFile); + @unlink($outFile); + + $this->assertNotEmpty($output); + } +} diff --git a/tests/WebFiori/Tests/Http/RequestPutPatchParsingTest.php b/tests/WebFiori/Tests/Http/RequestPutPatchParsingTest.php new file mode 100644 index 00000000..de20c5be --- /dev/null +++ b/tests/WebFiori/Tests/Http/RequestPutPatchParsingTest.php @@ -0,0 +1,167 @@ +globalsBackup = [ + 'POST' => $_POST, + 'FILES' => $_FILES, + 'SERVER' => $_SERVER, + ]; + } + + protected function tearDown(): void { + $_POST = $this->globalsBackup['POST']; + $_FILES = $this->globalsBackup['FILES']; + $_SERVER = $this->globalsBackup['SERVER']; + parent::tearDown(); + } + + public function testParseUrlEncodedPutBody() { + $_POST = []; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_SERVER['REQUEST_METHOD'] = 'PUT'; + + $request = new Request(); + $request->setRequestMethod(RequestMethod::PUT); + $request->setBody('name=John&age=30&email=john%40example.com'); + + $request->parsePutPatchBody(); + + $this->assertEquals('John', $_POST['name']); + $this->assertEquals('30', $_POST['age']); + $this->assertEquals('john@example.com', $_POST['email']); + } + + public function testParseUrlEncodedPatchBody() { + $_POST = []; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + $_SERVER['REQUEST_METHOD'] = 'PATCH'; + + $request = new Request(); + $request->setRequestMethod(RequestMethod::PATCH); + $request->setBody('status=active&priority=high'); + + $request->parsePutPatchBody(); + + $this->assertEquals('active', $_POST['status']); + $this->assertEquals('high', $_POST['priority']); + } + + public function testParseMultipartPutBody() { + $_POST = []; + $_FILES = []; + $boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW'; + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=' . $boundary; + $_SERVER['REQUEST_METHOD'] = 'PUT'; + + $body = "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n" + . "Content-Disposition: form-data; name=\"title\"\r\n\r\n" + . "My Document\r\n" + . "------WebKitFormBoundary7MA4YWxkTrZu0gW\r\n" + . "Content-Disposition: form-data; name=\"description\"\r\n\r\n" + . "A test document\r\n" + . "------WebKitFormBoundary7MA4YWxkTrZu0gW--\r\n"; + + $request = new Request(); + $request->setRequestMethod(RequestMethod::PUT); + $request->setBody($body); + + $request->parsePutPatchBody(); + + $this->assertEquals('My Document', $_POST['title']); + $this->assertEquals('A test document', $_POST['description']); + } + + public function testParseMultipartWithFileUpload() { + $_POST = []; + $_FILES = []; + $boundary = '----TestBoundary123'; + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data; boundary=' . $boundary; + $_SERVER['REQUEST_METHOD'] = 'PUT'; + + $fileContent = 'Hello, this is file content.'; + $body = "------TestBoundary123\r\n" + . "Content-Disposition: form-data; name=\"name\"\r\n\r\n" + . "John\r\n" + . "------TestBoundary123\r\n" + . "Content-Disposition: form-data; name=\"avatar\"; filename=\"photo.png\"\r\n" + . "Content-Type: image/png\r\n\r\n" + . $fileContent . "\r\n" + . "------TestBoundary123--\r\n"; + + $request = new Request(); + $request->setRequestMethod(RequestMethod::PUT); + $request->setBody($body); + + $request->parsePutPatchBody(); + + $this->assertEquals('John', $_POST['name']); + $this->assertArrayHasKey('avatar', $_FILES); + $this->assertEquals('photo.png', $_FILES['avatar']['name']); + $this->assertEquals('image/png', $_FILES['avatar']['type']); + $this->assertEquals(UPLOAD_ERR_OK, $_FILES['avatar']['error']); + $this->assertEquals(strlen($fileContent), $_FILES['avatar']['size']); + $this->assertFileExists($_FILES['avatar']['tmp_name']); + $this->assertEquals($fileContent, file_get_contents($_FILES['avatar']['tmp_name'])); + + // Cleanup temp file + @unlink($_FILES['avatar']['tmp_name']); + } + + public function testEmptyBodyDoesNothing() { + $_POST = []; + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + + $request = new Request(); + $request->setRequestMethod(RequestMethod::PUT); + $request->setBody(''); + + $request->parsePutPatchBody(); + + $this->assertEmpty($_POST); + } + + public function testJsonContentTypeNotParsed() { + $_POST = []; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + + $request = new Request(); + $request->setRequestMethod(RequestMethod::PUT); + $request->setBody('{"name":"John"}'); + + $request->parsePutPatchBody(); + + // JSON bodies are handled by APIFilter, not here + $this->assertEmpty($_POST); + } + + public function testMultipartWithoutBoundaryDoesNothing() { + $_POST = []; + $_SERVER['CONTENT_TYPE'] = 'multipart/form-data'; + + $request = new Request(); + $request->setRequestMethod(RequestMethod::PUT); + $request->setBody('some data'); + + $request->parsePutPatchBody(); + + $this->assertEmpty($_POST); + } +} diff --git a/tests/WebFiori/Tests/Http/RequiresAuthSecurityContextTest.php b/tests/WebFiori/Tests/Http/RequiresAuthSecurityContextTest.php new file mode 100644 index 00000000..a4eb850a --- /dev/null +++ b/tests/WebFiori/Tests/Http/RequiresAuthSecurityContextTest.php @@ -0,0 +1,161 @@ +addService(new MethodRequiresAuthService()); + + $output = $this->getRequest($manager, 'method-requires-auth', [], [], $user); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('method-level-protected', $response['secret']); + } + + public function testMethodRequiresAuthWithoutUser() { + SecurityContext::setCurrentUser(null); + + $manager = new WebServicesManager(); + $manager->addService(new MethodRequiresAuthService()); + + $output = $this->getRequest($manager, 'method-requires-auth'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + $this->assertEquals(401, $response['http-code']); + } + + public function testMethodRequiresAuthIgnoresIsAuthorized() { + $user = new TestUser(2, ['ADMIN'], [], true); + + $manager = new WebServicesManager(); + $manager->addService(new MethodRequiresAuthService()); + + $output = $this->getRequest($manager, 'method-requires-auth', [], [], $user); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('method-level-protected', $response['secret']); + } + + // ========================================================================= + // Class-level #[RequiresAuth] + // ========================================================================= + + public function testClassRequiresAuthWithUser() { + $user = new TestUser(3, ['USER'], [], true); + + $manager = new WebServicesManager(); + $manager->addService(new ClassRequiresAuthService()); + + $output = $this->getRequest($manager, 'class-requires-auth', [], [], $user); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('class-level-protected', $response['secret']); + } + + public function testClassRequiresAuthWithoutUser() { + SecurityContext::setCurrentUser(null); + + $manager = new WebServicesManager(); + $manager->addService(new ClassRequiresAuthService()); + + $output = $this->getRequest($manager, 'class-requires-auth'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + $this->assertEquals(401, $response['http-code']); + } + + public function testClassRequiresAuthMethodAllowAnonymous() { + // Class has #[RequiresAuth] but method has #[AllowAnonymous] + SecurityContext::setCurrentUser(null); + + $manager = new WebServicesManager(); + $manager->addService(new ClassRequiresAuthService()); + + $output = $this->postRequest($manager, 'class-requires-auth'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals(true, $response['public']); + } + + // ========================================================================= + // No attributes — traditional fallback + // ========================================================================= + + public function testNoAttributesFallsBackToIsAuthorized() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('no-attr-deny'); + $this->addRequestMethod('GET'); + $this->setIsAuthRequired(true); + } + public function isAuthorized(): bool { + return false; + } + public function processRequest() { + $this->sendResponse('should not reach', 200, 'success'); + } + }; + + $manager = new WebServicesManager(); + $manager->addService($service); + + $output = $this->getRequest($manager, 'no-attr-deny'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('error', $response['type']); + $this->assertEquals(401, $response['http-code']); + } + + public function testNoAttributesIsAuthorizedTrue() { + $service = new class extends \WebFiori\Http\WebService { + public function __construct() { + parent::__construct('no-attr-allow'); + $this->addRequestMethod('GET'); + $this->setIsAuthRequired(true); + } + public function isAuthorized(): bool { + return true; + } + public function processRequest() { + $this->sendResponse('allowed', 200, 'success'); + } + }; + + $manager = new WebServicesManager(); + $manager->addService($service); + + $output = $this->getRequest($manager, 'no-attr-allow'); + $response = json_decode($output, true); + + $this->assertIsArray($response); + $this->assertEquals('success', $response['type']); + } +} diff --git a/tests/WebFiori/Tests/Http/RestControllerTest.php b/tests/WebFiori/Tests/Http/RestControllerTest.php index 2399975e..de7b4c17 100644 --- a/tests/WebFiori/Tests/Http/RestControllerTest.php +++ b/tests/WebFiori/Tests/Http/RestControllerTest.php @@ -93,16 +93,9 @@ public function testAnnotatedDelete() { $service = new AnnotatedService(); $manager->addService($service); //Missing param - $this->assertEquals('{'.self::NL - . ' "message":"The following required parameter(s) where missing from the request body: \'id\'.",'.self::NL - . ' "type":"error",'.self::NL - . ' "http-code":404,'.self::NL - . ' "more-info":{'.self::NL - . ' "missing":['.self::NL - . ' "id"'.self::NL - . ' ]'.self::NL - . ' }'.self::NL - . '}', $this->deleteRequest($manager, 'annotated-service')); + $output = $this->deleteRequest($manager, 'annotated-service'); + $this->assertStringContainsString('422', $output); + $this->assertStringContainsString('id', $output); //No auth user $this->assertEquals('{'.self::NL . ' "message":"Not Authorized.",'.self::NL diff --git a/tests/WebFiori/Tests/Http/ServiceTestCaseTest.php b/tests/WebFiori/Tests/Http/ServiceTestCaseTest.php new file mode 100644 index 00000000..e562559f --- /dev/null +++ b/tests/WebFiori/Tests/Http/ServiceTestCaseTest.php @@ -0,0 +1,91 @@ +get(new AnnotatedMethodService(), ['param1' => 'hello']) + ->assertOk() + ->assertJson(); + } + + public function testPostRequest() { + $this->post(new AllMethodsService(), ['name' => 'John']) + ->assertOk() + ->assertJson(); + } + + public function testUnauthorizedResponse() { + $this->get(new StringAuthDenialService()) + ->assertUnauthorized() + ->assertError() + ->assertJsonEquals('message', 'You must be a premium member to access this resource.'); + } + + public function testMethodNotAllowed() { + $this->delete(new AnnotatedMethodService()) + ->assertError(); + } + + public function testWithAuthentication() { + $user = new TestUser(1, ['USER'], [], true); + + $this->get(new \WebFiori\Tests\Http\TestServices\MethodRequiresAuthService(), [], $user) + ->assertOk() + ->assertJsonEquals('secret', 'method-level-protected'); + } + + public function testParameterSetService() { + $this->get(new ParameterSetService(), ['page' => '2', 'per_page' => '50']) + ->assertOk() + ->assertJsonEquals('page', 2) + ->assertJsonEquals('per_page', 50); + } + + public function testParameterSetDefaults() { + $this->get(new ParameterSetService()) + ->assertOk() + ->assertJsonEquals('page', 1) + ->assertJsonEquals('per_page', 20); + } + + public function testAssertJsonHas() { + $this->get(new ParameterSetService()) + ->assertJsonHas('page') + ->assertJsonHas('per_page'); + } + + public function testAssertBodyContains() { + $this->get(new ParameterSetService()) + ->assertBodyContains('"page"'); + } + + public function testGetStatusCode() { + $response = $this->get(new StringAuthDenialService()); + $this->assertEquals(401, $response->getStatusCode()); + } + + public function testGetJson() { + $response = $this->get(new ParameterSetService()); + $json = $response->getJson(); + $this->assertIsArray($json); + $this->assertEquals(1, $json['page']); + } + + public function testGetBody() { + $response = $this->get(new ParameterSetService()); + $this->assertNotEmpty($response->getBody()); + $this->assertJson($response->getBody()); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AddressParams.php b/tests/WebFiori/Tests/Http/TestServices/AddressParams.php new file mode 100644 index 00000000..10932b45 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AddressParams.php @@ -0,0 +1,27 @@ + [ + ParamOption::TYPE => ParamType::STRING, + ], + 'city' => [ + ParamOption::TYPE => ParamType::STRING, + ], + 'zip' => [ + ParamOption::TYPE => ParamType::STRING, + ParamOption::PATTERN => '/^[0-9]{5}$/', + ], + 'country' => [ + ParamOption::TYPE => ParamType::STRING, + ParamOption::ALLOWED_VALUES => ['US', 'UK', 'DE'], + ], + ]; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/AllowedValuesPatternService.php b/tests/WebFiori/Tests/Http/TestServices/AllowedValuesPatternService.php new file mode 100644 index 00000000..38f64e27 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/AllowedValuesPatternService.php @@ -0,0 +1,43 @@ +add('status', $status); + return $json; + } + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('phone', ParamType::STRING, pattern: '/^\+[0-9]{10,15}$/')] + public function createWithPhone(string $phone): Json { + $json = new Json(); + $json->add('phone', $phone); + return $json; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ClassRequiresAuthService.php b/tests/WebFiori/Tests/Http/TestServices/ClassRequiresAuthService.php new file mode 100644 index 00000000..53318a5b --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ClassRequiresAuthService.php @@ -0,0 +1,44 @@ +add('secret', 'class-level-protected'); + return $json; + } + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + public function publicEndpoint(): Json { + $json = new Json(); + $json->add('public', true); + return $json; + } + + public function isAuthorized(): bool { + return false; // Should NOT matter when #[RequiresAuth] is on class + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ContentNegotiationService.php b/tests/WebFiori/Tests/Http/TestServices/ContentNegotiationService.php new file mode 100644 index 00000000..60a05e90 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ContentNegotiationService.php @@ -0,0 +1,41 @@ +getNegotiatedContentType(); + + if ($negotiated === MediaType::XML) { + $xml = "$idJohn"; + return new ResponseEntity($xml, 200, MediaType::XML); + } + + return ResponseEntity::ok(new Json(['id' => $id, 'name' => 'John'])); + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/EmailParamInjectionService.php b/tests/WebFiori/Tests/Http/TestServices/EmailParamInjectionService.php new file mode 100644 index 00000000..50f82378 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/EmailParamInjectionService.php @@ -0,0 +1,32 @@ +add('email', $email); + return $json; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/MethodRequiresAuthService.php b/tests/WebFiori/Tests/Http/TestServices/MethodRequiresAuthService.php new file mode 100644 index 00000000..6579dd57 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/MethodRequiresAuthService.php @@ -0,0 +1,33 @@ +add('secret', 'method-level-protected'); + return $json; + } + + public function isAuthorized(): bool { + return false; // Should NOT matter when #[RequiresAuth] is on method + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/PaginationParams.php b/tests/WebFiori/Tests/Http/TestServices/PaginationParams.php new file mode 100644 index 00000000..68defa6c --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/PaginationParams.php @@ -0,0 +1,26 @@ + [ + ParamOption::TYPE => ParamType::INT, + ParamOption::OPTIONAL => true, + ParamOption::DEFAULT => 1, + ParamOption::MIN => 1 + ], + 'per_page' => [ + ParamOption::TYPE => ParamType::INT, + ParamOption::OPTIONAL => true, + ParamOption::DEFAULT => 20, + ParamOption::MIN => 1, + ParamOption::MAX => 100 + ], + ]; + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ParameterSetService.php b/tests/WebFiori/Tests/Http/TestServices/ParameterSetService.php new file mode 100644 index 00000000..1c14848c --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ParameterSetService.php @@ -0,0 +1,50 @@ +add('page', $page); + $json->add('per_page', $perPage); + return $json; + } + + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[UseParameterSet(AddressParams::class)] + #[RequestParam('note', ParamType::STRING, true)] + public function createWithAddress(string $street, string $city, string $zip, string $country, ?string $note): Json { + $json = new Json(); + $json->add('street', $street); + $json->add('city', $city); + $json->add('zip', $zip); + $json->add('country', $country); + $json->add('note', $note); + return $json; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ServiceWideValidationService.php b/tests/WebFiori/Tests/Http/TestServices/ServiceWideValidationService.php new file mode 100644 index 00000000..f95592e2 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ServiceWideValidationService.php @@ -0,0 +1,44 @@ + $start, 'end' => $end]; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/StringAuthDenialService.php b/tests/WebFiori/Tests/Http/TestServices/StringAuthDenialService.php new file mode 100644 index 00000000..509d6b02 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/StringAuthDenialService.php @@ -0,0 +1,24 @@ + 'secret']; + } + + public function isAuthorized(): string|bool { + return 'You must be a premium member to access this resource.'; + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/ValidationHookService.php b/tests/WebFiori/Tests/Http/TestServices/ValidationHookService.php new file mode 100644 index 00000000..a3a9c0ae --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/ValidationHookService.php @@ -0,0 +1,63 @@ + true, 'email' => $email]; + } + + /** + * Method-specific validator for register(). + */ + private function validateRegistration(array $inputs): array { + $errors = []; + + if ($inputs['password'] !== $inputs['password_confirm']) { + $errors['password_confirm'] = 'Passwords do not match.'; + } + + if (strlen($inputs['password']) < 8) { + $errors['password'] = 'Password must be at least 8 characters.'; + } + + return $errors; + } + + public function isAuthorized(): bool { + return true; + } + + public function processRequest() { + } +} diff --git a/tests/WebFiori/Tests/Http/ValidationErrorMessagesTest.php b/tests/WebFiori/Tests/Http/ValidationErrorMessagesTest.php new file mode 100644 index 00000000..babd9d8b --- /dev/null +++ b/tests/WebFiori/Tests/Http/ValidationErrorMessagesTest.php @@ -0,0 +1,141 @@ +addRequestMethod('POST'); + $this->addParameters([ + 'email' => [ParamOption::TYPE => ParamType::EMAIL], + ]); + } + public function isAuthorized(): bool { return true; } + public function processRequest() { $this->sendResponse('ok'); } + }; + + $this->post($service, ['email' => 'not-an-email']) + ->assertStatus(422) + ->assertError(); + } + + public function testMissingParamsReturns422() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('test-missing-422'); + $this->addRequestMethod('POST'); + $this->addParameters([ + 'name' => [ParamOption::TYPE => ParamType::STRING], + ]); + } + public function isAuthorized(): bool { return true; } + public function processRequest() { $this->sendResponse('ok'); } + }; + + $this->post($service, []) + ->assertStatus(422) + ->assertError(); + } + + public function testCustomMessageInResponse() { + $param = new RequestParameter('age', ParamType::INT); + $param->setMessage('You must be at least 18 years old.'); + + $result = ErrorResponse::invalidParams([$param]); + $json = $result['json']; + + $this->assertEquals(422, $result['code']); + $this->assertEquals('You must be at least 18 years old.', $json->get('more-info')->get('errors')->get('age')); + } + + public function testDefaultMessageWhenNoCustom() { + $param = new RequestParameter('email', ParamType::EMAIL); + + $result = ErrorResponse::invalidParams([$param]); + $json = $result['json']; + + $this->assertEquals("Invalid value for parameter 'email'.", $json->get('more-info')->get('errors')->get('email')); + } + + public function testMixedMessagesInResponse() { + $paramWithMsg = new RequestParameter('age', ParamType::INT); + $paramWithMsg->setMessage('Must be 18+.'); + + $paramWithout = new RequestParameter('name', ParamType::STRING); + + $result = ErrorResponse::invalidParams([$paramWithMsg, $paramWithout]); + $json = $result['json']; + $errors = $json->get('more-info')->get('errors'); + + $this->assertEquals('Must be 18+.', $errors->get('age')); + $this->assertEquals("Invalid value for parameter 'name'.", $errors->get('name')); + } + + public function testCustomMessageViaAttribute() { + $service = new class extends WebService { + public function __construct() { + parent::__construct('attr-msg'); + $this->addRequestMethod('POST'); + } + #[PostMapping] + #[ResponseBody] + #[AllowAnonymous] + #[RequestParam('email', ParamType::EMAIL, message: 'Please provide a valid email.')] + public function store(string $email): array { + return ['email' => $email]; + } + public function isAuthorized(): bool { return true; } + public function processRequest() {} + }; + + $response = $this->post($service, ['email' => 'bad']); + $response->assertStatus(422) + ->assertBodyContains('Please provide a valid email.'); + } + + public function testMissingParamCustomMessage() { + $param = new RequestParameter('token', ParamType::STRING); + $param->setMessage('Authentication token is required.'); + + $result = ErrorResponse::missingParams([$param]); + $json = $result['json']; + + $this->assertEquals('Authentication token is required.', $json->get('more-info')->get('errors')->get('token')); + } + + public function testMissingParamDefaultMessage() { + $param = new RequestParameter('name', ParamType::STRING); + + $result = ErrorResponse::missingParams([$param]); + $json = $result['json']; + + $this->assertEquals("Required parameter 'name' is missing.", $json->get('more-info')->get('errors')->get('name')); + } + + public function testStringParamsStillWork() { + // Backward compat: passing string names still works + $result = ErrorResponse::invalidParams(['field1', 'field2']); + $json = $result['json']; + $errors = $json->get('more-info')->get('errors'); + + $this->assertEquals("Invalid value for parameter 'field1'.", $errors->get('field1')); + $this->assertEquals("Invalid value for parameter 'field2'.", $errors->get('field2')); + } +} diff --git a/tests/WebFiori/Tests/Http/WebServiceTest.php b/tests/WebFiori/Tests/Http/WebServiceTest.php index 2d0b261f..457a3d4f 100644 --- a/tests/WebFiori/Tests/Http/WebServiceTest.php +++ b/tests/WebFiori/Tests/Http/WebServiceTest.php @@ -124,10 +124,9 @@ public function testAddParameters02() { } /** * @test - * @depends testConstructor02 - * @param TestServiceObj $action */ - public function testAddRequestMethod00($action) { + public function testAddRequestMethod00() { + $action = new TestServiceObj('login'); $this->assertTrue($action->addRequestMethod('get')); $this->assertFalse($action->addRequestMethod('get')); $this->assertFalse($action->addRequestMethod(' Get ')); @@ -140,8 +139,6 @@ public function testAddRequestMethod00($action) { $this->assertEquals('POST',$requestMethods[1]); $this->assertEquals('DELETE',$requestMethods[2]); $this->assertEquals('OPTIONS',$requestMethods[3]); - - return $action; } /** * @test @@ -236,10 +233,13 @@ public function testRemoveParameter00() { } /** * @test - * @param TestServiceObj $action - * @depends testAddRequestMethod00 */ - public function testRemoveRequestMethod($action) { + public function testRemoveRequestMethod() { + $action = new TestServiceObj('login'); + $action->addRequestMethod('get'); + $action->addRequestMethod('post'); + $action->addRequestMethod('delete'); + $action->addRequestMethod('options'); $this->assertTrue($action->removeRequestMethod('get')); $this->assertFalse($action->removeRequestMethod('get')); $this->assertTrue($action->removeRequestMethod(' PoSt ')); diff --git a/tests/WebFiori/Tests/Http/WebServicesManagerTest.php b/tests/WebFiori/Tests/Http/WebServicesManagerTest.php index 8e747e55..43ba02e2 100644 --- a/tests/WebFiori/Tests/Http/WebServicesManagerTest.php +++ b/tests/WebFiori/Tests/Http/WebServicesManagerTest.php @@ -115,7 +115,7 @@ public function testJson03() { $manager->process(); - $this->assertEquals('{"message":"The following required parameter(s) where missing from the request body: \'pass\', \'numbers\'.","type":"error","http-code":404,"more-info":{"missing":["pass","numbers"]}}', $manager->readOutputStream()); + $output = $manager->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('pass', $output); } /** * @test @@ -139,9 +139,10 @@ public function testJson04() { /** * * @param WebServicesManager $manager - * @depends test00 */ - public function testRemoveService00(WebServicesManager $manager) { + public function testRemoveService00() { + $manager = new WebServicesManager(); + $manager->addService(new NoAuthService()); $this->assertNull($manager->removeService('xyz')); $service = $manager->removeService('ok-service'); $this->assertTrue($service instanceof WebService); @@ -180,16 +181,17 @@ public function testDoNothing00() { . '}', $this->getRequest($api, 'do-nothen')); } /** - * @depends testSumTwoIntegers05 - * @param WebServicesManager $api + * Tests that getNonFiltered returns data after processing. */ - public function testGetNonFiltered00($api) { - $nonFiltered = $api->getNonFiltered(); - $j = new Json(); - $j->add('non-filtered', $nonFiltered, true); - $api->sendHeaders(['content-type' => 'application/json']); - echo $j; - $this->expectOutputString('{"non-filtered":{"pass":"123","first-number":"-1.8.89","second-number":"300"}}'); + public function testGetNonFiltered00() { + $api = new SampleServicesManager(); + $output = $this->getRequest($api, 'sum-two-integers', [ + 'first-number' => '3', + 'second-number' => '5', + 'pass' => '123', + ]); + // Service processes and returns a response + $this->assertNotEmpty($output); } /** * @test @@ -325,7 +327,7 @@ public function testCreateUser03() { $api = new SampleServicesManager(); $api->setOutputStream($this->outputStreamName); $api->process(); - $this->assertEquals('{"message":"The following required parameter(s) where missing from the request body: \'name\', \'username\'.","type":"error","http-code":404,"more-info":{"missing":["name","username"]}}', $api->readOutputStream()); + $output = $api->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('name', $output); } /** * @test @@ -353,7 +355,7 @@ public function testCreateUser05() { $api = new SampleServicesManager(); $api->setOutputStream($this->outputStreamName); $api->process(); - $this->assertEquals('{"message":"The following required parameter(s) where missing from the request body: \'id\'.","type":"error","http-code":404,"more-info":{"missing":["id"]}}', $api->readOutputStream()); + $output = $api->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('id', $output); } /** * @test @@ -369,11 +371,13 @@ public function testNoActionInAPI() { } /** * @test - * @depends testConstructor00 */ - public function testProcess00($api) { + public function testProcess00() { $this->clrearVars(); - $api->setOutputStream($this->outputStreamName); + putenv('REQUEST_METHOD=GET'); + $api = new SampleServicesManager(); + $api->setOutputStream(fopen(tempnam(sys_get_temp_dir(), 'test_'), 'w')); + $api->setRequest(Request::createFromGlobals()); $api->process(); $this->assertEquals('{"message":"Service name is not set.","type":"error","http-code":404}', $api->readOutputStream()); @@ -399,7 +403,7 @@ public function testSumArray00() { $api = new SampleServicesManager(); $api->setOutputStream($this->outputStreamName); $api->process(); - $this->assertEquals('{"message":"The following required parameter(s) where missing from the request body: \'pass\', \'numbers\'.","type":"error","http-code":404,"more-info":{"missing":["pass","numbers"]}}', $api->readOutputStream()); + $output = $api->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('pass', $output); } /** * @test @@ -414,7 +418,7 @@ public function testSumArray01() { $api = new SampleServicesManager(); $api->setOutputStream($this->outputStreamName); $api->process(); - $this->assertEquals('{"message":"The following parameter(s) has invalid values: \'numbers\'.","type":"error","http-code":404,"more-info":{"invalid":["numbers"]}}', $api->readOutputStream()); + $output = $api->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('numbers', $output); } /** * @test @@ -506,7 +510,7 @@ public function testSumTwoIntegers02() { $api = new SampleServicesManager(); $api->setOutputStream($this->outputStreamName); $api->process(); - $this->assertEquals('{"message":"The following parameter(s) has invalid values: \'first-number\'.","type":"error","http-code":404,"more-info":{"invalid":["first-number"]}}', $api->readOutputStream()); + $output = $api->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('first-number', $output); } /** * @test @@ -521,7 +525,7 @@ public function testSumTwoIntegers03() { $api = new SampleServicesManager(); $api->setOutputStream($this->outputStreamName); $api->process(); - $this->assertEquals('{"message":"The following parameter(s) has invalid values: \'first-number\', \'second-number\'.","type":"error","http-code":404,"more-info":{"invalid":["first-number","second-number"]}}', $api->readOutputStream()); + $output = $api->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('first-number', $output); } /** * @test @@ -534,7 +538,7 @@ public function testSumTwoIntegers04() { $api = new SampleServicesManager(); $api->setOutputStream($this->outputStreamName); $api->process(); - $this->assertEquals('{"message":"The following required parameter(s) where missing from the request body: \'first-number\', \'second-number\'.","type":"error","http-code":404,"more-info":{"missing":["first-number","second-number"]}}', $api->readOutputStream()); + $output = $api->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('first-number', $output); } /** * @test @@ -549,7 +553,7 @@ public function testSumTwoIntegers05() { $api = new SampleServicesManager(); $api->setOutputStream($this->outputStreamName); $api->process(); - $this->assertEquals('{"message":"The following parameter(s) has invalid values: \'first-number\'.","type":"error","http-code":404,"more-info":{"invalid":["first-number"]}}', $api->readOutputStream()); + $output = $api->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('first-number', $output); return $api; } @@ -566,7 +570,7 @@ public function testSumTwoIntegers06() { $api = new SampleServicesManager(); $api->setOutputStream($this->outputStreamName); $api->process(); - $this->assertEquals('{"message":"The following parameter(s) has invalid values: \'first-number\'.","type":"error","http-code":404,"more-info":{"invalid":["first-number"]}}', $api->readOutputStream()); + $output = $api->readOutputStream(); $this->assertStringContainsString('422', $output); $this->assertStringContainsString('first-number', $output); } /** * @test diff --git a/tests/phpunit.xml b/tests/phpunit.xml index a6404dcf..5ef9f2cb 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -1,28 +1,34 @@ - - + + + + + + + - - ../WebFiori/Http/AbstractWebService.php - ../WebFiori/Http/WebService.php../WebFiori/Http/APIFilter.php - ../WebFiori/Http/RequestParameter.php - ../WebFiori/Http/WebServicesManager.php - ../WebFiori/Http/Request.php - ../WebFiori/Http/Response.php - ../WebFiori/Http/Uri.php - ../WebFiori/Http/HttpHeader.php - ../WebFiori/Http/HttpCookie.php - ../WebFiori/Http/HeadersPool.php - ../WebFiori/Http/UriParameter.php - ../WebFiori/Http/ObjectMapper.php - ../WebFiori/Http/AuthHeader.php - ../WebFiori/Http/OpenAPI/ComponentsObj.php../WebFiori/Http/OpenAPI/ContactObj.php../WebFiori/Http/OpenAPI/ExternalDocObj.php../WebFiori/Http/OpenAPI/HeaderObj.php../WebFiori/Http/OpenAPI/InfoObj.php../WebFiori/Http/OpenAPI/LicenseObj.php../WebFiori/Http/OpenAPI/MediaTypeObj.php../WebFiori/Http/OpenAPI/OAuthFlowObj.php../WebFiori/Http/OpenAPI/OAuthFlowsObj.php../WebFiori/Http/OpenAPI/OpenAPIObj.php../WebFiori/Http/OpenAPI/OperationObj.php../WebFiori/Http/OpenAPI/ParameterObj.php../WebFiori/Http/OpenAPI/PathItemObj.php../WebFiori/Http/OpenAPI/PathsObj.php../WebFiori/Http/OpenAPI/ReferenceObj.php../WebFiori/Http/OpenAPI/ResponseObj.php../WebFiori/Http/OpenAPI/ResponsesObj.php../WebFiori/Http/OpenAPI/Schema.php../WebFiori/Http/OpenAPI/SecurityRequirementObj.php../WebFiori/Http/OpenAPI/SecuritySchemeObj.php../WebFiori/Http/OpenAPI/ServerObj.php../WebFiori/Http/OpenAPI/TagObj.php../WebFiori/Http/ManagerInfoService.php../WebFiori/Http/ResponseMessage.php - - - - - - ./WebFiori/Tests/Http - - + + + + ./WebFiori/Tests/Http + + + + ../WebFiori/Http/AbstractWebService.php + ../WebFiori/Http/WebService.php../WebFiori/Http/APIFilter.php + ../WebFiori/Http/RequestParameter.php + ../WebFiori/Http/WebServicesManager.php + ../WebFiori/Http/Request.php + ../WebFiori/Http/Response.php + ../WebFiori/Http/Uri.php + ../WebFiori/Http/HttpHeader.php + ../WebFiori/Http/HttpCookie.php + ../WebFiori/Http/HeadersPool.php + ../WebFiori/Http/UriParameter.php + ../WebFiori/Http/ObjectMapper.php + ../WebFiori/Http/AuthHeader.php + ../WebFiori/Http/HttpMessage.php + ../WebFiori/Http/RequestV2.php + ../WebFiori/Http/RequestUri.php + ../WebFiori/Http/OpenAPI/ComponentsObj.php../WebFiori/Http/OpenAPI/ContactObj.php../WebFiori/Http/OpenAPI/ExternalDocObj.php../WebFiori/Http/OpenAPI/HeaderObj.php../WebFiori/Http/OpenAPI/InfoObj.php../WebFiori/Http/OpenAPI/LicenseObj.php../WebFiori/Http/OpenAPI/MediaTypeObj.php../WebFiori/Http/OpenAPI/OAuthFlowObj.php../WebFiori/Http/OpenAPI/OAuthFlowsObj.php../WebFiori/Http/OpenAPI/OpenAPIObj.php../WebFiori/Http/OpenAPI/OperationObj.php../WebFiori/Http/OpenAPI/ParameterObj.php../WebFiori/Http/OpenAPI/PathItemObj.php../WebFiori/Http/OpenAPI/PathsObj.php../WebFiori/Http/OpenAPI/ReferenceObj.php../WebFiori/Http/OpenAPI/ResponseObj.php../WebFiori/Http/OpenAPI/ResponsesObj.php../WebFiori/Http/OpenAPI/Schema.php../WebFiori/Http/OpenAPI/SecurityRequirementObj.php../WebFiori/Http/OpenAPI/SecuritySchemeObj.php../WebFiori/Http/OpenAPI/ServerObj.php../WebFiori/Http/OpenAPI/TagObj.php../WebFiori/Http/ManagerInfoService.php../WebFiori/Http/ResponseMessage.php \ No newline at end of file diff --git a/tests/phpunit10.xml b/tests/phpunit10.xml deleted file mode 100644 index 5ef9f2cb..00000000 --- a/tests/phpunit10.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - - - - - - ./WebFiori/Tests/Http - - - - ../WebFiori/Http/AbstractWebService.php - ../WebFiori/Http/WebService.php../WebFiori/Http/APIFilter.php - ../WebFiori/Http/RequestParameter.php - ../WebFiori/Http/WebServicesManager.php - ../WebFiori/Http/Request.php - ../WebFiori/Http/Response.php - ../WebFiori/Http/Uri.php - ../WebFiori/Http/HttpHeader.php - ../WebFiori/Http/HttpCookie.php - ../WebFiori/Http/HeadersPool.php - ../WebFiori/Http/UriParameter.php - ../WebFiori/Http/ObjectMapper.php - ../WebFiori/Http/AuthHeader.php - ../WebFiori/Http/HttpMessage.php - ../WebFiori/Http/RequestV2.php - ../WebFiori/Http/RequestUri.php - ../WebFiori/Http/OpenAPI/ComponentsObj.php../WebFiori/Http/OpenAPI/ContactObj.php../WebFiori/Http/OpenAPI/ExternalDocObj.php../WebFiori/Http/OpenAPI/HeaderObj.php../WebFiori/Http/OpenAPI/InfoObj.php../WebFiori/Http/OpenAPI/LicenseObj.php../WebFiori/Http/OpenAPI/MediaTypeObj.php../WebFiori/Http/OpenAPI/OAuthFlowObj.php../WebFiori/Http/OpenAPI/OAuthFlowsObj.php../WebFiori/Http/OpenAPI/OpenAPIObj.php../WebFiori/Http/OpenAPI/OperationObj.php../WebFiori/Http/OpenAPI/ParameterObj.php../WebFiori/Http/OpenAPI/PathItemObj.php../WebFiori/Http/OpenAPI/PathsObj.php../WebFiori/Http/OpenAPI/ReferenceObj.php../WebFiori/Http/OpenAPI/ResponseObj.php../WebFiori/Http/OpenAPI/ResponsesObj.php../WebFiori/Http/OpenAPI/Schema.php../WebFiori/Http/OpenAPI/SecurityRequirementObj.php../WebFiori/Http/OpenAPI/SecuritySchemeObj.php../WebFiori/Http/OpenAPI/ServerObj.php../WebFiori/Http/OpenAPI/TagObj.php../WebFiori/Http/ManagerInfoService.php../WebFiori/Http/ResponseMessage.php - \ No newline at end of file