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() { + } +}