diff --git a/README.md b/README.md
index 89cae14..c91418e 100644
--- a/README.md
+++ b/README.md
@@ -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('...', 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.
diff --git a/WebFiori/Http/Annotations/Produces.php b/WebFiori/Http/Annotations/Produces.php
new file mode 100644
index 0000000..12c9b3e
--- /dev/null
+++ b/WebFiori/Http/Annotations/Produces.php
@@ -0,0 +1,34 @@
+contentTypes = $contentTypes;
+ }
+}
diff --git a/WebFiori/Http/ErrorResponse.php b/WebFiori/Http/ErrorResponse.php
index 6e7c923..ac50597 100644
--- a/WebFiori/Http/ErrorResponse.php
+++ b/WebFiori/Http/ErrorResponse.php
@@ -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.
*/
diff --git a/WebFiori/Http/MediaType.php b/WebFiori/Http/MediaType.php
new file mode 100644
index 0000000..4d18afb
--- /dev/null
+++ b/WebFiori/Http/MediaType.php
@@ -0,0 +1,30 @@
+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.
*
@@ -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);
@@ -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].
*
diff --git a/tests/WebFiori/Tests/Http/ContentNegotiationTest.php b/tests/WebFiori/Tests/Http/ContentNegotiationTest.php
new file mode 100644
index 0000000..c3b3eed
--- /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/TestServices/ContentNegotiationService.php b/tests/WebFiori/Tests/Http/TestServices/ContentNegotiationService.php
new file mode 100644
index 0000000..60a05e9
--- /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() {
+ }
+}