diff --git a/README.md b/README.md index d7cd071..489dab2 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,116 @@ $openapi = new OpenApi([ $json = \cebe\openapi\Writer::writeToJson($openapi); ``` +Write empty Security Requirement Object (`{}`): + +```php +$openapi = new OpenApi([ + ... + 'security' => new SecurityRequirements([ + [] + ] + ... +]); +``` + +```yaml +... +security: + - {} +... +``` + +Write security for multiple authentication: + +```php +$openapi = new OpenApi([ + ... + 'components' => new Components([ + 'securitySchemes' => [ + 'BearerAuth' => new SecurityScheme([ + 'type' => 'http', + 'scheme' => 'bearer', + ]), + 'BasicAuth' => new SecurityScheme([ + 'type' => 'http', + 'scheme' => 'basic', + ]), + 'ApiKeyAuth' => new SecurityScheme([ + 'type' => 'apiKey', + 'name' => 'X-API-Key', + 'in' => 'header' + ]) + ], + ]), + 'security' => new SecurityRequirements([ + [ + 'BearerAuth' => new SecurityRequirement([]), + 'BasicAuth' => new SecurityRequirement([]) + ], + [ + 'ApiKeyAuth' => new SecurityRequirement([]) + ] + ]), + ... +]); +``` + +```yaml +security: + - + BearerAuth: [] + BasicAuth: [] + - + ApiKeyAuth: [] + +``` + +Write single authentication (note that both below case will yield same output): + +```php +$openapi = new OpenApi([ + ... + 'components' => new Components([ + 'securitySchemes' => [ + 'BearerAuth' => new SecurityScheme([ + 'type' => 'http', + 'scheme' => 'bearer', + ]) + ], + ]), + 'security' => new SecurityRequirements([ + 'BearerAuth' => new SecurityRequirement([]) + ]), + ... +]); +``` + +```php +$openapi = new OpenApi([ + ... + 'components' => new Components([ + 'securitySchemes' => [ + 'BearerAuth' => new SecurityScheme([ + 'type' => 'http', + 'scheme' => 'bearer', + ]) + ], + ]), + 'security' => new SecurityRequirements([ + [ + 'BearerAuth' => new SecurityRequirement([]) + ] + ]), + ... +]); +``` + +```yaml +security: + - + BearerAuth: [] +``` + ### Reading API Description Files and Resolving References In the above we have passed the raw JSON or YAML data to the Reader. In order to be able to resolve diff --git a/src/spec/SecurityRequirements.php b/src/spec/SecurityRequirements.php index cbda1ba..58a5d3b 100644 --- a/src/spec/SecurityRequirements.php +++ b/src/spec/SecurityRequirements.php @@ -24,12 +24,20 @@ public function __construct(array $data) parent::__construct($data); foreach ($data as $index => $value) { - if (is_numeric($index)) { // read - $this->_securityRequirements[array_keys($value)[0]] = new SecurityRequirement(array_values($value)[0]); - } else { // write - $this->_securityRequirements[$index] = $value; + if (is_numeric($index) && $value === []) { # empty Security Requirement Object (`{}`) = anonymous access + $this->_securityRequirements[$index] = []; + continue; + } + + if (is_string($index)) { + $this->_securityRequirements[] = [$index => $value instanceof SecurityRequirement ? $value : new SecurityRequirement($value)]; + } elseif (is_numeric($index)) { + foreach ($value as $innerIndex => $subValue) { + $this->_securityRequirements[$index][$innerIndex] = $subValue instanceof SecurityRequirement ? $subValue : new SecurityRequirement($subValue); + } } } + if ($data === []) { $this->_securityRequirements = []; } @@ -59,20 +67,50 @@ protected function performValidation() public function getSerializableData() { $data = []; - foreach ($this->_securityRequirements ?? [] as $name => $securityRequirement) { - /** @var SecurityRequirement $securityRequirement */ - $data[] = [$name => $securityRequirement->getSerializableData()]; + + foreach ($this->_securityRequirements ?? [] as $outerIndex => $content) { + if (is_string($outerIndex)) { + $data[] = [$outerIndex => $content->getSerializableData()]; + } elseif (is_numeric($outerIndex)) { + if ($content === []) { + $data[$outerIndex] = (object)$content; + continue; + } + $innerResult = []; + foreach ($content as $innerIndex => $innerContent) { + $result = is_object($innerContent) && method_exists($innerContent, 'getSerializableData') ? $innerContent->getSerializableData() : $innerContent; + $innerResult[$innerIndex] = $result; + } + $data[$outerIndex] = (object)$innerResult; + } } return $data; } public function getRequirement(string $name) { - return $this->_securityRequirements[$name] ?? null; + return static::searchKey($this->_securityRequirements, $name); } public function getRequirements() { return $this->_securityRequirements; } + + private static function searchKey(array $array, string $searchKey) + { + foreach ($array as $key => $value) { + if ($key === $searchKey) { + return $value; + } + if (is_array($value)) { + $mt = __METHOD__; + $result = $mt($value, $searchKey); + if ($result !== null) { + return $result; // key found in deeply nested/associative array + } + } + } + return null; // key not found + } } diff --git a/tests/WriterTest.php b/tests/WriterTest.php index 355a744..d6112d0 100644 --- a/tests/WriterTest.php +++ b/tests/WriterTest.php @@ -8,6 +8,7 @@ use cebe\openapi\spec\SecurityRequirement; use cebe\openapi\spec\SecurityRequirements; use cebe\openapi\spec\SecurityScheme; +use cebe\openapi\Writer; class WriterTest extends \PHPUnit\Framework\TestCase { @@ -27,7 +28,7 @@ public function testWriteJson() { $openapi = $this->createOpenAPI(); - $json = \cebe\openapi\Writer::writeToJson($openapi); + $json = Writer::writeToJson($openapi); $this->assertEquals(preg_replace('~\R~', "\n", << 'something' ]); - $json = \cebe\openapi\Writer::writeToJson($openapi); + $json = Writer::writeToJson($openapi); $this->assertEquals(preg_replace('~\R~', "\n", <<createOpenAPI(); - $yaml = \cebe\openapi\Writer::writeToYaml($openapi); + $yaml = Writer::writeToYaml($openapi); $this->assertEquals(preg_replace('~\R~', "\n", << [], ]); - $json = \cebe\openapi\Writer::writeToJson($openapi); + $json = Writer::writeToJson($openapi); $this->assertEquals(preg_replace('~\R~', "\n", << [], ]); - $yaml = \cebe\openapi\Writer::writeToYaml($openapi); + $yaml = Writer::writeToYaml($openapi); $this->assertEquals(preg_replace('~\R~', "\n", <<assertEquals(preg_replace('~\R~', "\n", <<assertEquals(preg_replace('~\R~', "\n", <<assertEquals(preg_replace('~\R~', "\n", << [], ]); + $yaml = Writer::writeToYaml($openapi); - $yaml = \cebe\openapi\Writer::writeToYaml($openapi); + // case 2 + $openapi2 = $this->createOpenAPI([ + 'components' => new Components([ + 'securitySchemes' => [ + 'BearerAuth' => new SecurityScheme([ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'AuthToken and JWT Format' # optional, arbitrary value for documentation purposes + ]) + ], + ]), + 'security' => new SecurityRequirements([ + [ + 'BearerAuth' => new SecurityRequirement([]) + ] + ]), + 'paths' => [], + ]); + $yaml2 = Writer::writeToYaml($openapi2); - $this->assertEquals(preg_replace('~\R~', "\n", <<assertEquals(preg_replace('~\R~', "\n", $expected), $yaml); + $this->assertEquals(preg_replace('~\R~', "\n", $expected), $yaml2); } } diff --git a/tests/data/issue/238/spec.yml b/tests/data/issue/238/spec.yml new file mode 100644 index 0000000..df9c183 --- /dev/null +++ b/tests/data/issue/238/spec.yml @@ -0,0 +1,28 @@ +openapi: 3.0.0 +info: + title: Secured API + version: 1.0.0 +paths: + /global-secured: + get: + responses: + '200': + description: OK + /path-secured: + get: + security: + - {} + responses: + '200': + description: OK +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + BearerAuth: + type: http + scheme: bearer +security: + - ApiKeyAuth: [] diff --git a/tests/data/issue/242/multiple_auth.yml b/tests/data/issue/242/multiple_auth.yml new file mode 100644 index 0000000..904663f --- /dev/null +++ b/tests/data/issue/242/multiple_auth.yml @@ -0,0 +1,39 @@ +openapi: 3.0.0 +info: + title: Multiple auth + version: 1.0.0 +paths: {} +components: + securitySchemes: + BasicAuth: + type: http + scheme: basic + + BearerAuth: + type: http + scheme: bearer + + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + + OpenID: + type: openIdConnect + openIdConnectUrl: https://example.com/.well-known/openid-configuration + + OAuth2: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: https://example.com/oauth/authorize + tokenUrl: https://example.com/oauth/token + scopes: + read: Grants read access + write: Grants write access + admin: Grants access to admin operations +security: + - BasicAuth: [] + BearerAuth: [] + - ApiKeyAuth: [] + OAuth2: [read] \ No newline at end of file diff --git a/tests/data/issue/242/spec.json b/tests/data/issue/242/spec.json new file mode 100644 index 0000000..5321bb9 --- /dev/null +++ b/tests/data/issue/242/spec.json @@ -0,0 +1,41 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "My API", + "version": "1, 2" + }, + "paths": { + "/v1/users/profile": { + "get": { + "operationId": "V1GetUserProfile", + "summary": "Returns the user profile", + "responses": { + "200": { + "description": "dummy" + } + }, + "security": [ + { + "test_test": ["test:scope:foo"] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "test_test": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://example.com/openid-connect/auth", + "tokenUrl": "https://example.com/openid-connect/token", + "scopes": { + "test:scope:foo": "test_scope" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/tests/data/issue/242/spec2.json b/tests/data/issue/242/spec2.json new file mode 100644 index 0000000..331d865 --- /dev/null +++ b/tests/data/issue/242/spec2.json @@ -0,0 +1,45 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "API Documentation", + "description": "All API endpoints are presented here.", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://127.0.0.1:8080/" + } + ], + "paths": { + "/endpoint": { + "get": { + "responses": { + "200": { + "description": "OK" + } + }, + "security": [ + { + "apiKey": [], + "bearerAuth": [] + } + ] + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-APi-Key" + }, + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "JWT Authorization header using the Bearer scheme." + } + } + } +} \ No newline at end of file diff --git a/tests/data/issue/242/spec2.yml b/tests/data/issue/242/spec2.yml new file mode 100644 index 0000000..eebe517 --- /dev/null +++ b/tests/data/issue/242/spec2.yml @@ -0,0 +1,31 @@ +# https://github.com/cebe/php-openapi/issues/242#issuecomment-2886431173 +openapi: 3.0.0 +info: + title: API Documentation + description: All API endpoints are presented here. + version: 1.0.0 +servers: + - url: http://127.0.0.1:8080/ + +paths: + + /endpoint: + get: + responses: + '200': + description: OK + security: + - apiKey: [] + bearerAuth: [] +components: + securitySchemes: + apiKey: + type: apiKey + in: header + name: X-APi-Key + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: JWT Authorization header using the Bearer scheme. + diff --git a/tests/issues/238/Issue238Test.php b/tests/issues/238/Issue238Test.php new file mode 100644 index 0000000..530bd48 --- /dev/null +++ b/tests/issues/238/Issue238Test.php @@ -0,0 +1,137 @@ +assertInstanceOf(\cebe\openapi\SpecObjectInterface::class, $openapi); + $this->assertInstanceOf(\cebe\openapi\spec\SecurityRequirements::class, $openapi->paths->getPath('/path-secured')->getOperations()['get']->security); + $this->assertSame(json_decode(json_encode($openapi->paths->getPath('/path-secured')->getOperations()['get']->security->getSerializableData()), true), [[]]); + + $openapiJson = Reader::readFromJson(<<assertInstanceOf(\cebe\openapi\SpecObjectInterface::class, $openapiJson); + $this->assertInstanceOf(\cebe\openapi\spec\SecurityRequirements::class, $openapiJson->paths->getPath('/path-secured')->getOperations()['get']->security); + $this->assertSame(json_decode(json_encode($openapiJson->paths->getPath('/path-secured')->getOperations()['get']->security->getSerializableData()), true), [[]]); + } + + public function test238AddSupportForEmptySecurityRequirementObjectInSecurityRequirementWrite() + { + $openapi = $this->createOpenAPI([ + 'security' => new SecurityRequirements([ + [] + ]), + ]); + + $yaml = Writer::writeToYaml($openapi); + + $this->assertEquals(preg_replace('~\R~', "\n", <<createOpenAPI([ + 'security' => new SecurityRequirements([ + [] + ]), + ]); + + $json = Writer::writeToJson($openapiJson); + + $this->assertEquals(preg_replace('~\R~', "\n", << '3.0.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + ], + 'paths' => [], + ], $merge)); + } +} diff --git a/tests/issues/242/Issue242Test.php b/tests/issues/242/Issue242Test.php new file mode 100644 index 0000000..7ab89cc --- /dev/null +++ b/tests/issues/242/Issue242Test.php @@ -0,0 +1,283 @@ +assertInstanceOf(SpecObjectInterface::class, $openapi); + + $dirSep = DIRECTORY_SEPARATOR; + $cmd = 'php ' . dirname(__DIR__, 3) . "{$dirSep}bin{$dirSep}php-openapi validate " . $file . " 2>&1"; + exec($cmd, $op, $ec); + $this->assertSame($this->removeCliFormatting($op[0]), 'The supplied API Description validates against the OpenAPI v3.0 schema.'); + $this->assertSame(0, $ec); + } + + private function removeCliFormatting($string) + { + // Regex to remove ANSI escape codes + return preg_replace('/\e\[[0-9;]*m/', '', $string); + } + + public function test242Case2() # https://github.com/cebe/php-openapi/issues/242#issuecomment-2886431173 + { + // read in yml + $file = dirname(__DIR__, 2) . '/data/issue/242/spec2.yml'; + $openapi = Reader::readFromYamlFile($file); + $this->assertInstanceOf(SpecObjectInterface::class, $openapi); + $this->assertSame(json_decode(json_encode($openapi->paths['/endpoint']->get->security->getSerializableData()), true), [ + [ + 'apiKey' => [], + 'bearerAuth' => [] + ] + ]); + + # write back to yml + $yaml = Writer::writeToYaml($openapi); + $this->assertEquals(preg_replace('~\R~', "\n", <<assertInstanceOf(SpecObjectInterface::class, $openapi); + $this->assertSame(json_decode(json_encode($openapi->paths['/endpoint']->get->security->getSerializableData()), true), [ + [ + 'apiKey' => [], + 'bearerAuth' => [] + ] + ]); + + // write back in json + $yaml = Writer::writeToJson($openapi); + $this->assertEquals(preg_replace('~\R~', "\n", <<assertInstanceOf(SpecObjectInterface::class, $openapi); + $act = json_decode(json_encode($openapi->security->getSerializableData()), true); + $this->assertSame([], $act[0]['BasicAuth']); + + # write back to yml + $yaml = Writer::writeToYaml($openapi); + $this->assertEquals(preg_replace('~\R~', "\n", <<createOpenAPI([ + 'components' => new Components([ + 'securitySchemes' => [ + 'BearerAuth' => new SecurityScheme([ + 'type' => 'http', + 'scheme' => 'bearer', + ]), + 'BasicAuth' => new SecurityScheme([ + 'type' => 'http', + 'scheme' => 'basic', + ]), + 'ApiKeyAuth' => new SecurityScheme([ + 'type' => 'apiKey', + 'name' => 'X-API-Key', + 'in' => 'header' + ]) + ], + ]), + 'security' => new SecurityRequirements([ + [ + 'BearerAuth' => new SecurityRequirement([]), + 'BasicAuth' => new SecurityRequirement([]) + ], + [ + 'ApiKeyAuth' => new SecurityRequirement([]) + ] + ]), + 'paths' => [], + ]); + + + $yaml = \cebe\openapi\Writer::writeToYaml($openapi); + + $this->assertEquals(preg_replace('~\R~', "\n", << '3.0.0', + 'info' => [ + 'title' => 'Test API', + 'version' => '1.0.0', + ], + 'paths' => [], + ], $merge)); + } +} diff --git a/tests/spec/OpenApiTest.php b/tests/spec/OpenApiTest.php index 433bd35..7999dae 100644 --- a/tests/spec/OpenApiTest.php +++ b/tests/spec/OpenApiTest.php @@ -88,6 +88,21 @@ public function assertAllInstanceOf($className, $array) } } + public function assertFewInstanceOf($className, $array) + { + foreach($array as $k => $v) { + if (is_numeric($k) && $v === []) { # https://github.com/cebe/php-openapi/issues/238 + continue; + } + + if (is_array($v)) { + $this->{__FUNCTION__}($className, $v); + } else { + $this->assertInstanceOf($className, $v, "Asserting that item with key '$k' is instance of $className"); + } + } + } + public function specProvider() { // examples from https://github.com/OAI/OpenAPI-Specification/tree/master/examples/v3.0 @@ -222,7 +237,7 @@ public function testSpecs($openApiFile) // security $openapi->security !== null && $this->assertInstanceOf(\cebe\openapi\spec\SecurityRequirements::class, $openapi->security); - $openapi->security !== null && $this->assertAllInstanceOf(\cebe\openapi\spec\SecurityRequirement::class, $openapi->security->getRequirements()); + $openapi->security !== null && $this->assertFewInstanceOf(\cebe\openapi\spec\SecurityRequirement::class, $openapi->security->getRequirements()); // tags $this->assertAllInstanceOf(\cebe\openapi\spec\Tag::class, $openapi->tags);