Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
4425b60
fix(#112): EMAIL param injection returns null for JSON body requests
Jun 1, 2026
4a45a68
chore: unify phpunit config, update CI to workflows v1.2.5
Jun 1, 2026
8147a7d
Merge pull request #123 from WebFiori/fix/112-email-param-injection
usernane Jun 1, 2026
161ee39
feat(#114): add allowed-values and pattern validation to RequestParam…
Jun 1, 2026
e74d92d
feat(#114): add allowed-values and pattern validation to RequestParam…
Jun 1, 2026
acaf604
Merge branch 'feat/114-allowed-values-pattern' of https://github.com/…
usernane Jun 1, 2026
f86f5c8
Merge pull request #124 from WebFiori/feat/114-allowed-values-pattern
usernane Jun 1, 2026
19a1b8f
feat(#111): allow isAuthorized() to return string as denial reason
Jun 1, 2026
56cd360
fix: remove duplicate property and method declarations
Jun 1, 2026
b54cfc9
Merge branch 'dev' into feat/111-isauthorized-string-reason
usernane Jun 1, 2026
9186547
Merge pull request #125 from WebFiori/feat/111-isauthorized-string-re…
usernane Jun 1, 2026
969e391
refactor(#118): move PUT/PATCH body parsing into Request class
Jun 1, 2026
67c71a3
Merge pull request #126 from WebFiori/refactor/118-put-patch-parsing-…
usernane Jun 1, 2026
ee8dbbe
refactor(#119): extract ErrorResponse helper class
Jun 1, 2026
48dbf1d
Merge pull request #127 from WebFiori/refactor/119-extract-error-resp…
usernane Jun 1, 2026
63a00e6
refactor(#120): extract OpenAPIGenerator from WebServicesManager
Jun 1, 2026
baae969
Merge pull request #128 from WebFiori/refactor/120-extract-openapi-ge…
usernane Jun 1, 2026
d06f00e
feat(#121): create RequestProcessor class
Jun 1, 2026
7810da5
Merge pull request #129 from WebFiori/refactor/121-create-request-pro…
usernane Jun 1, 2026
489b2d0
deprecate(#122): mark WebServicesManager as deprecated
Jun 1, 2026
c247e83
Merge pull request #130 from WebFiori/refactor/122-deprecate-web-serv…
usernane Jun 1, 2026
90e6914
feat(#116): add reusable parameter sets with ParameterSet interface
Jun 1, 2026
b191618
refactor: extract OpenAPIObject base class to reduce duplication
Jun 1, 2026
04f7d99
chore: exclude tests and examples from SonarCloud duplication detection
Jun 1, 2026
5bd309a
Merge pull request #131 from WebFiori/feat/116-reusable-parameter-sets
usernane Jun 1, 2026
8107785
fix: add missing PatchMapping annotation class
Jun 1, 2026
2598fb9
feat(#117): #[RequiresAuth] checks SecurityContext::isAuthenticated()…
Jun 1, 2026
b60aa66
Merge pull request #133 from WebFiori/feat/117-requires-auth-security…
usernane Jun 1, 2026
e28ef9f
fix(#132): preserve native PHP boolean false in APIFilter
Jun 1, 2026
01ce116
feat: add ServiceTestCase and TestResponse for simplified service tes…
Jun 1, 2026
851d6a0
feat(#115): add cross-field validation with #[Validate] attribute
Jun 1, 2026
2793917
Merge pull request #134 from WebFiori/feat/115-cross-field-validation
usernane Jun 1, 2026
26341e2
feat(#113): change validation errors to 422 and add custom error mess…
Jun 1, 2026
0826ce1
feat: add content negotiation with #[Produces] attribute and MediaTyp…
Jun 1, 2026
ea28ea9
Merge pull request #135 from WebFiori/feat/113-validation-status-code…
usernane Jun 1, 2026
bc33b35
Merge pull request #136 from WebFiori/feat/content-negotiation
usernane Jun 1, 2026
2b9a2e3
fix: remove @depends from tests for PHPUnit 12 compatibility
Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions .github/workflows/php81.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}



9 changes: 3 additions & 6 deletions .github/workflows/php82.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}



27 changes: 4 additions & 23 deletions .github/workflows/php83.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
9 changes: 3 additions & 6 deletions .github/workflows/php84.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}



24 changes: 18 additions & 6 deletions .github/workflows/php85.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
105 changes: 104 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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('<user>...</user>', 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

Expand Down
48 changes: 43 additions & 5 deletions WebFiori/Http/APIFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,12 @@
$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;

Expand Down Expand Up @@ -344,6 +346,9 @@
if (gettype($toBeFiltered) == 'array') {
return $toBeFiltered;
}
if (gettype($toBeFiltered) == 'boolean') {
return $toBeFiltered;
}
$toBeFiltered = strip_tags($toBeFiltered);

$paramObj = $def['parameter'];
Expand Down Expand Up @@ -383,6 +388,10 @@
}
}

if ($returnVal !== self::INVALID) {
$returnVal = self::checkAllowedAndPattern($returnVal, $paramObj);
}

return $returnVal;
}
private static function applyCustomFilterFunc($def, $toBeFiltered) {
Expand Down Expand Up @@ -410,7 +419,7 @@

return $returnVal;
}
private function applyJsonBasicFilter(Json $extraClean, $toBeFiltered, $def) {

Check failure on line 422 in WebFiori/Http/APIFilter.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 22 to the 20 allowed.

See more on https://sonarcloud.io/project/issues?id=WebFiori_http&issues=AZ6Eba8RDLpd2qK8o4M4&open=AZ6Eba8RDLpd2qK8o4M4&pullRequest=137
$paramObj = $def['parameter'];
$paramType = $paramObj->getType();
$name = $paramObj->getName();
Expand All @@ -420,12 +429,13 @@
$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));
Expand Down Expand Up @@ -545,6 +555,11 @@

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) {
Expand Down Expand Up @@ -965,4 +980,27 @@

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;
}
}
Loading
Loading