Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,33 @@ For more examples, check the [examples](examples/) directory in this repository.
- [`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

Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
Expand Down
34 changes: 34 additions & 0 deletions WebFiori/Http/Annotations/Produces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/**
* This file is licensed under MIT License.
*
* Copyright (c) 2026-present WebFiori Framework
*
* For more information on the license, please visit:
* https://github.com/WebFiori/.github/blob/main/LICENSE
*/
namespace WebFiori\Http\Annotations;

use Attribute;

/**
* Declares the content types a method can produce.
*
* Used for content negotiation — the framework matches the client's Accept
* header against the declared types. If no match, returns 406 Not Acceptable.
*
* Usage:
* ```php
* #[Produces(MediaType::JSON, MediaType::XML)]
* public function getUser(): ResponseEntity { ... }
* ```
*/
#[Attribute(Attribute::TARGET_METHOD)]
class Produces {
public readonly array $contentTypes;

public function __construct(string ...$contentTypes) {
$this->contentTypes = $contentTypes;
}
}
19 changes: 19 additions & 0 deletions WebFiori/Http/ErrorResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,25 @@ public static function unauthorized(?string $message = null) : array {

return ['json' => $json, 'code' => 401];
}
/**
* Generates a 406 Not Acceptable response.
*
* @param array $supported The content types the server can produce.
*
* @return array{json: Json, code: int} The response body and HTTP code.
*/
public static function notAcceptable(array $supported = []) : array {
$json = new Json();
$json->add('message', 'Not Acceptable');
$json->add('type', WebService::E);
$json->add('http-code', 406);

if (!empty($supported)) {
$json->add('more-info', new Json(['supported' => $supported]));
}

return ['json' => $json, 'code' => 406];
}
/**
* Formats an array of parameter names into a comma-separated quoted string.
*/
Expand Down
30 changes: 30 additions & 0 deletions WebFiori/Http/MediaType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/**
* This file is licensed under MIT License.
*
* Copyright (c) 2026-present WebFiori Framework
*
* For more information on the license, please visit:
* https://github.com/WebFiori/http/blob/master/LICENSE
*/
namespace WebFiori\Http;

/**
* Constants for common HTTP media types (MIME types).
*
* Use with #[Produces] attribute for content negotiation.
*
* @author Ibrahim
*/
class MediaType {
const CSV = 'text/csv';
const FORM = 'application/x-www-form-urlencoded';
const HTML = 'text/html';
const JSON = 'application/json';
const MULTIPART = 'multipart/form-data';
const OCTET_STREAM = 'application/octet-stream';
const PDF = 'application/pdf';
const PLAIN = 'text/plain';
const XML = 'application/xml';
}
112 changes: 112 additions & 0 deletions WebFiori/Http/WebService.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,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.
Expand Down Expand Up @@ -694,6 +700,17 @@ public function isAuthorized() : string|bool {
public function isAuthRequired() : bool {
return $this->requireAuth;
}
/**
* Returns the content type negotiated from the client's Accept header.
*
* Only meaningful when the method has a #[Produces] attribute.
* Defaults to 'application/json' if no negotiation occurred.
*
* @return string The negotiated media type.
*/
public function getNegotiatedContentType() : string {
return $this->negotiatedContentType ?? MediaType::JSON;
}
/**
* Checks if the class has a #[RequiresAuth] attribute.
*
Expand Down Expand Up @@ -766,6 +783,21 @@ 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);
Expand Down Expand Up @@ -1352,6 +1384,86 @@ 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].
*
Expand Down
Loading
Loading