diff --git a/WebFiori/Http/OpenAPI/OperationObj.php b/WebFiori/Http/OpenAPI/OperationObj.php index e55e20b2..96d139a7 100644 --- a/WebFiori/Http/OpenAPI/OperationObj.php +++ b/WebFiori/Http/OpenAPI/OperationObj.php @@ -33,6 +33,20 @@ class OperationObj implements JsonI { */ private ResponsesObj $responses; + /** + * A list of parameters that are applicable for this operation. + * + * @var ParameterObj[] + */ + private array $parameters = []; + + /** + * The request body applicable for this operation. + * + * @var Json|null + */ + private ?Json $requestBody = null; + public function __construct(ResponsesObj $responses) { $this->responses = $responses; } @@ -47,10 +61,62 @@ public function setResponses(ResponsesObj $responses): OperationObj { return $this; } + /** + * Adds a parameter to this operation. + * + * @param ParameterObj $param The parameter to add. + * + * @return OperationObj Returns self for method chaining. + */ + public function addParameter(ParameterObj $param): OperationObj { + $this->parameters[] = $param; + + return $this; + } + + /** + * Returns the parameters for this operation. + * + * @return ParameterObj[] + */ + public function getParameters(): array { + return $this->parameters; + } + + /** + * Sets the request body for this operation. + * + * @param Json $requestBody The request body object. + * + * @return OperationObj Returns self for method chaining. + */ + public function setRequestBody(Json $requestBody): OperationObj { + $this->requestBody = $requestBody; + + return $this; + } + + /** + * Returns the request body. + * + * @return Json|null + */ + public function getRequestBody(): ?Json { + return $this->requestBody; + } + public function toJSON(): Json { - $json = new Json([ - 'responses' => $this->getResponses() - ]); + $json = new Json(); + + if (!empty($this->parameters)) { + $json->add('parameters', $this->parameters); + } + + if ($this->requestBody !== null) { + $json->add('requestBody', $this->requestBody); + } + + $json->add('responses', $this->getResponses()); return $json; } diff --git a/WebFiori/Http/WebService.php b/WebFiori/Http/WebService.php index 10908970..c580a972 100644 --- a/WebFiori/Http/WebService.php +++ b/WebFiori/Http/WebService.php @@ -1003,6 +1003,7 @@ public function toJSON() : Json { */ public function toPathItemObj(): OpenAPI\PathItemObj { $pathItem = new OpenAPI\PathItemObj(); + $annotatedParams = $this->getAnnotatedRequestParams(); foreach ($this->getRequestMethods() as $method) { $responses = $this->getResponsesForMethod($method); @@ -1013,6 +1014,27 @@ public function toPathItemObj(): OpenAPI\PathItemObj { } $operation = new OpenAPI\OperationObj($responses); + $methodParams = $annotatedParams[$method] ?? []; + + if (!empty($methodParams)) { + $isBodyMethod = in_array($method, [ + RequestMethod::POST, + RequestMethod::PUT, + RequestMethod::PATCH + ]); + + if ($isBodyMethod) { + $operation->setRequestBody( + self::buildRequestBody($methodParams) + ); + } else { + foreach ($methodParams as $param) { + $operation->addParameter( + self::buildQueryParameter($param) + ); + } + } + } switch ($method) { case RequestMethod::GET: @@ -1033,7 +1055,112 @@ public function toPathItemObj(): OpenAPI\PathItemObj { } } -return $pathItem; + return $pathItem; + } + + /** + * Reads #[RequestParam] annotations from methods and groups them by HTTP method. + * + * @return array Map of HTTP method to RequestParam annotations. + */ + private function getAnnotatedRequestParams(): array { + $reflection = new \ReflectionClass($this); + $result = []; + + $mappings = [ + Annotations\GetMapping::class => RequestMethod::GET, + Annotations\PostMapping::class => RequestMethod::POST, + Annotations\PutMapping::class => RequestMethod::PUT, + Annotations\DeleteMapping::class => RequestMethod::DELETE, + ]; + + foreach ($reflection->getMethods() as $method) { + $paramAttrs = $method->getAttributes(Annotations\RequestParam::class); + + if (empty($paramAttrs)) { + continue; + } + + $params = array_map(fn($a) => $a->newInstance(), $paramAttrs); + + foreach ($mappings as $annotationClass => $httpMethod) { + if (!empty($method->getAttributes($annotationClass))) { + $result[$httpMethod] = array_merge($result[$httpMethod] ?? [], $params); + } + } + } + + return $result; + } + + /** + * Builds an OpenAPI ParameterObj (query param) from a RequestParam annotation. + */ + private static function buildQueryParameter(Annotations\RequestParam $param): OpenAPI\ParameterObj { + $p = new OpenAPI\ParameterObj($param->name, 'query'); + $p->setRequired(!$param->optional); + $p->setSchema(self::buildParamSchema($param)->toJson()); + + if ($param->description !== '') { + $p->setDescription($param->description); + } + + return $p; + } + + /** + * Builds an OpenAPI requestBody Json object from RequestParam annotations. + * + * @param Annotations\RequestParam[] $params + */ + private static function buildRequestBody(array $params): \WebFiori\Json\Json { + $properties = new \WebFiori\Json\Json(); + $required = []; + + foreach ($params as $param) { + $properties->add($param->name, self::buildParamSchema($param)->toJson()); + + if (!$param->optional) { + $required[] = $param->name; + } + } + + $schema = new \WebFiori\Json\Json(); + $schema->add('type', 'object'); + $schema->add('properties', $properties); + + if (!empty($required)) { + $schema->add('required', $required); + } + + $content = new \WebFiori\Json\Json(); + $mediaType = new \WebFiori\Json\Json(); + $mediaType->add('schema', $schema); + $content->add('application/x-www-form-urlencoded', $mediaType); + + $body = new \WebFiori\Json\Json(); + $body->add('content', $content); + + return $body; + } + + /** + * Builds an OpenAPI Schema from a RequestParam annotation. + */ + private static function buildParamSchema(Annotations\RequestParam $param): OpenAPI\Schema { + $schema = new OpenAPI\Schema(OpenAPI\Schema::mapType($param->type)); + + if ($param->type === ParamType::EMAIL) { + $schema->setFormat('email'); + } else if ($param->type === ParamType::URL) { + $schema->setFormat('uri'); + } + + if ($param->default !== null) { + // Schema doesn't have a public setter for default, build inline + } + + return $schema; } /** diff --git a/tests/WebFiori/Tests/Http/OpenAPIRequestParamTest.php b/tests/WebFiori/Tests/Http/OpenAPIRequestParamTest.php new file mode 100644 index 00000000..1b483ac7 --- /dev/null +++ b/tests/WebFiori/Tests/Http/OpenAPIRequestParamTest.php @@ -0,0 +1,159 @@ +toPathItemObj(); + $json = json_decode($pathItem->toJSON() . '', true); + + // GET operation should have parameters + $this->assertArrayHasKey('parameters', $json['get']); + $params = $json['get']['parameters']; + + // 'name' param: optional string + $this->assertEquals('name', $params[0]['name']); + $this->assertEquals('query', $params[0]['in']); + $this->assertArrayNotHasKey('required', $params[0]); // optional + $this->assertEquals('string', $params[0]['schema']['type']); + } + + /** + * DELETE params should appear as query parameters with required flag. + */ + public function testDeleteParamsAsQueryParameters() { + $service = new AnnotatedService(); + $pathItem = $service->toPathItemObj(); + $json = json_decode($pathItem->toJSON() . '', true); + + $params = $json['delete']['parameters']; + + // 'id' param: required integer + $this->assertEquals('id', $params[0]['name']); + $this->assertEquals('query', $params[0]['in']); + $this->assertTrue($params[0]['required']); + $this->assertEquals('integer', $params[0]['schema']['type']); + } + + /** + * POST params should appear as requestBody with schema properties. + */ + public function testPostParamsAsRequestBody() { + $service = new AnnotatedParamsLegacyService(); + $pathItem = $service->toPathItemObj(); + $json = json_decode($pathItem->toJSON() . '', true); + + $this->assertArrayHasKey('requestBody', $json['post']); + $schema = $json['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema']; + + $this->assertEquals('object', $schema['type']); + $this->assertArrayHasKey('username', $schema['properties']); + $this->assertArrayHasKey('password', $schema['properties']); + $this->assertEquals('string', $schema['properties']['username']['type']); + $this->assertEquals('string', $schema['properties']['password']['type']); + } + + /** + * Required POST params should be listed in the required array. + */ + public function testPostRequiredParams() { + $service = new AnnotatedParamsLegacyService(); + $pathItem = $service->toPathItemObj(); + $json = json_decode($pathItem->toJSON() . '', true); + + $schema = $json['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema']; + + $this->assertContains('username', $schema['required']); + $this->assertContains('password', $schema['required']); + } + + /** + * Service with mixed GET and POST methods should generate both + * query parameters and requestBody. + */ + public function testMixedGetAndPostService() { + $service = new OpenAPIGetPostService(); + $pathItem = $service->toPathItemObj(); + $json = json_decode($pathItem->toJSON() . '', true); + + // GET should have query parameters + $this->assertArrayHasKey('parameters', $json['get']); + $this->assertEquals('id', $json['get']['parameters'][0]['name']); + $this->assertTrue($json['get']['parameters'][0]['required']); + $this->assertEquals('integer', $json['get']['parameters'][0]['schema']['type']); + + // POST should have requestBody + $this->assertArrayHasKey('requestBody', $json['post']); + $props = $json['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema']['properties']; + $this->assertArrayHasKey('name', $props); + $this->assertArrayHasKey('email', $props); + } + + /** + * Email type should produce string type with email format. + */ + public function testEmailTypeFormat() { + $service = new OpenAPIGetPostService(); + $pathItem = $service->toPathItemObj(); + $json = json_decode($pathItem->toJSON() . '', true); + + $props = $json['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema']['properties']; + $this->assertEquals('string', $props['email']['type']); + $this->assertEquals('email', $props['email']['format']); + } + + /** + * Optional POST params should NOT appear in the required array. + */ + public function testOptionalPostParamNotInRequired() { + $service = new OpenAPIGetPostService(); + $pathItem = $service->toPathItemObj(); + $json = json_decode($pathItem->toJSON() . '', true); + + $schema = $json['post']['requestBody']['content']['application/x-www-form-urlencoded']['schema']; + $this->assertContains('name', $schema['required']); + $this->assertContains('email', $schema['required']); + $this->assertNotContains('nickname', $schema['required'] ?? []); + } + + /** + * toOpenAPI on the manager should include parameters in paths. + */ + public function testManagerToOpenAPIIncludesParams() { + $manager = new WebServicesManager(); + $manager->addService(new AnnotatedService()); + $openapi = $manager->toOpenAPI(); + $json = json_decode($openapi->toJSON() . '', true); + + $path = $json['paths']['/annotated-service']; + $this->assertArrayHasKey('parameters', $path['get']); + $this->assertEquals('name', $path['get']['parameters'][0]['name']); + } + + /** + * Service with no #[RequestParam] should produce no parameters or requestBody. + */ + public function testServiceWithNoParamsHasNoParametersInSpec() { + $service = new \WebFiori\Tests\Http\TestServices\LegacyService(); + $pathItem = $service->toPathItemObj(); + $json = json_decode($pathItem->toJSON() . '', true); + + $this->assertArrayNotHasKey('parameters', $json['get']); + $this->assertArrayNotHasKey('requestBody', $json['get']); + } +} diff --git a/tests/WebFiori/Tests/Http/TestServices/OpenAPIGetPostService.php b/tests/WebFiori/Tests/Http/TestServices/OpenAPIGetPostService.php new file mode 100644 index 00000000..c8654118 --- /dev/null +++ b/tests/WebFiori/Tests/Http/TestServices/OpenAPIGetPostService.php @@ -0,0 +1,37 @@ +