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
72 changes: 69 additions & 3 deletions WebFiori/Http/OpenAPI/OperationObj.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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;
}
Expand Down
129 changes: 128 additions & 1 deletion WebFiori/Http/WebService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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:
Expand All @@ -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<string, Annotations\RequestParam[]> 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;
}

/**
Expand Down
159 changes: 159 additions & 0 deletions tests/WebFiori/Tests/Http/OpenAPIRequestParamTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php
namespace WebFiori\Tests\Http;

use PHPUnit\Framework\TestCase;
use WebFiori\Http\WebServicesManager;
use WebFiori\Tests\Http\TestServices\AnnotatedService;
use WebFiori\Tests\Http\TestServices\AnnotatedParamsLegacyService;
use WebFiori\Tests\Http\TestServices\OpenAPIGetPostService;

/**
* Tests that #[RequestParam] annotations are included in OpenAPI spec generation.
*
* @see https://github.com/WebFiori/http/issues/100
*/
class OpenAPIRequestParamTest extends TestCase {

/**
* GET params should appear as query parameters in the spec.
*/
public function testGetParamsAsQueryParameters() {
$service = new AnnotatedService();
$pathItem = $service->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']);
}
}
Loading
Loading