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