diff --git a/README.md b/README.md index 4d7eada7..25746009 100644 --- a/README.md +++ b/README.md @@ -23,40 +23,50 @@ SwaggerBake requires CakePHP4 and a few dependencies that will be automatically composer require cnizzardini/cakephp-swagger-bake ``` -Add the plugin to `Application.php`: +Run `bin/cake plugin load SwaggerBake` or manually load the plugin: ```php -$this->addPlugin('SwaggerBake'); +# src/Application.php +public function bootstrap(): void +{ + // other logic... + $this->addPlugin('SwaggerBake'); +} ``` -## Basic Usage +## Setup -Get going in just four easy steps: +For standard applications that have not split their API into plugins, the automated setup should work. Otherwise +use the manual setup. -- Create a base swagger.yml file in `config\swagger.yml`. An example file is provided [here](assets/swagger.yml). +### Automated Setup -- Create a `config/swagger_bake.php` file. See the example file [here](assets/swagger_bake.php) for further -explanation. +Run `bin/cake swagger install` -- Create a route for the SwaggerUI page in `config/routes.php`. See Extensibility for other ways to diplay Swagger. +Create a route for the SwaggerUI page in `config/routes.php`, example: ```php -$builder->connect('/api', ['controller' => 'Swagger', 'action' => 'index', 'plugin' => 'SwaggerBake']); +$builder->connect('/your-api-path', ['controller' => 'Swagger', 'action' => 'index', 'plugin' => 'SwaggerBake']); ``` -- Use the `swagger bake` command to generate your swagger documentation. +### Manual Setup -```sh -bin/cake swagger bake -``` +- Create a base swagger.yml file in `config\swagger.yml`. An example file is provided [here](assets/swagger.yml). -Using the above example you should now see your swagger documentation after browsing to http://your-project/api +- Create a `config/swagger_bake.php` file. See the example file [here](assets/swagger_bake.php) for further +explanation. -### Hot Reload Swagger JSON +- Create a route for the SwaggerUI page in `config/routes.php`. See Extensibility for other ways to diplay Swagger. + +```php +$builder->connect('/your-api-path', ['controller' => 'Swagger', 'action' => 'index', 'plugin' => 'SwaggerBake']); +``` -You can enable hot reloading. This setting re-generates swagger.json on each reload of Swagger UI. Simply set -`hotReload` equal to `true` (using `Configure::read('debug')` is recommended) in your `config/swagger_bake.php` file. +## Complete Setup +If Hot Reload is enabled ([see config](assets/swagger_bake.php)) then you should be able to browse to the above +route. Otherwise you must first run `bin/cake swagger bake` to generate your swagger documentation. + ## Automatic Documentation I built this library to reduce the need for annotations to build documentation. SwaggerBake will automatically @@ -80,13 +90,14 @@ components > schemas > Exception as your Swagger documentations Exception schema ## Doc Blocks SwaggerBake will parse your [DocBlocks](https://docs.phpdoc.org/latest/guides/docblocks.html) for information. The -first line reads as the Path Summary and the second as the Path Description, `@see`, `@deprecated`, and `@throws` are -also supported. Throw tags use the Exception classes HTTP status code. For instance, a `MethodNotAllowedException` -displays as a 405 response in Swagger UI, while a standard PHP Exception displays as a 500 code. +first line reads as the Operation Summary and the second as the Operation Description, `@see`, `@deprecated`, and +`@throws` are also supported. Throw tags use the Exception classes HTTP status code. For instance, a +`MethodNotAllowedException` displays as a 405 response in Swagger UI, while a standard PHP Exception displays as a 500 +code. ```php /** - * Swagger Path Summary + * Swagger Operation Summary * * This displays as the operations long description * diff --git a/assets/swagger_bake.php b/assets/swagger_bake.php index 9bc4c89a..eaf5de1e 100644 --- a/assets/swagger_bake.php +++ b/assets/swagger_bake.php @@ -43,7 +43,7 @@ */ return [ 'SwaggerBake' => [ - 'prefix' => '/api', + 'prefix' => '/your-relative-api-url', 'yml' => '/config/swagger.yml', 'json' => '/webroot/swagger.json', 'webPath' => '/swagger.json', diff --git a/src/Command/BakeCommand.php b/src/Command/BakeCommand.php index 281ff6f9..38b3f0cf 100644 --- a/src/Command/BakeCommand.php +++ b/src/Command/BakeCommand.php @@ -9,6 +9,10 @@ use SwaggerBake\Lib\Configuration; use SwaggerBake\Lib\Factory\SwaggerFactory; +/** + * Class BakeCommand + * @package SwaggerBake\Command + */ class BakeCommand extends Command { /** @@ -26,6 +30,10 @@ public function execute(Arguments $args, ConsoleIo $io) $output = $config->getJson(); $swagger = (new SwaggerFactory())->create(); + foreach ($swagger->getOperationsWithNoHttp20x() as $operation) { + triggerWarning('Operation ' . $operation->getOperationId() . ' does not have a HTTP 20x response'); + } + $swagger->writeFile($output); $io->out("Swagger File Created: $output"); diff --git a/src/Command/InstallCommand.php b/src/Command/InstallCommand.php new file mode 100644 index 00000000..3332caf0 --- /dev/null +++ b/src/Command/InstallCommand.php @@ -0,0 +1,78 @@ +hr(); + $io->out("| SwaggerBake Install"); + $io->hr(); + + $io->info('This will create, but not overwrite config/swagger.yml and config/swagger_bake.php'); + + $io->out( + 'If your API exists in a plugin or you have some other non-standard setup, please follow ' . + 'the manual installation steps.' + ); + + if (strtoupper($io->ask('Continue?', 'Y')) !== 'Y') { + return; + } + + $assets = __DIR__ . DS . '..' . DS . '..' . DS . 'assets'; + if (!dir($assets)) { + $io->error('Unable to locate assets directory, please install manually'); + return; + } + + if (file_exists(CONFIG . 'swagger.yml') || file_exists(CONFIG . 'swagger_bake.php')) { + $answer = $io->ask('The installer found existing SwaggerBake config files. Overwrite?', 'Y'); + if (strtoupper($answer) !== 'Y') { + return; + } + } + + if (!copy("$assets/swagger.yml", CONFIG . 'swagger.yml')) { + $io->error('Unable to copy swagger.yml, check permissions'); + return; + } + + if (!copy("$assets/swagger_bake.php", CONFIG . 'swagger_bake.php')) { + $io->error('Unable to copy swagger_bake.php, check permissions'); + return; + } + + $path = $io->ask('What is your relative API path (e.g. /api)'); + if (!empty($path)) { + $contents = file_get_contents(CONFIG . 'swagger.yml'); + $contents = str_replace('YOUR-SERVER-HERE', $path, $contents); + file_put_contents(CONFIG . 'swagger.yml', $contents); + + $contents = file_get_contents(CONFIG . 'swagger_bake.php'); + $contents = str_replace('/your-relative-api-url', $path, $contents); + file_put_contents(CONFIG . 'swagger_bake.php', $contents); + } + + $io->success('Installation Complete!'); + + $io->out('Now just add a route in your config/routes.php for SwaggerUI and you\'re ready to go!'); + } +} diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index 8400df3d..6aef447f 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -14,6 +14,10 @@ use SwaggerBake\Lib\Utility\DataTypeConversion; use SwaggerBake\Lib\Utility\ValidateConfiguration; +/** + * Class ModelCommand + * @package SwaggerBake\Command + */ class ModelCommand extends Command { /** diff --git a/src/Command/RouteCommand.php b/src/Command/RouteCommand.php index 70131d3b..e82a8636 100644 --- a/src/Command/RouteCommand.php +++ b/src/Command/RouteCommand.php @@ -14,6 +14,10 @@ use SwaggerBake\Lib\Configuration; use SwaggerBake\Lib\Utility\ValidateConfiguration; +/** + * Class RouteCommand + * @package SwaggerBake\Command + */ class RouteCommand extends Command { /** diff --git a/src/Controller/AppController.php b/src/Controller/AppController.php index e959496e..500c3f71 100644 --- a/src/Controller/AppController.php +++ b/src/Controller/AppController.php @@ -7,4 +7,9 @@ class AppController extends BaseController { + public function initialize(): void + { + parent::initialize(); + $this->loadComponent('Flash'); + } } diff --git a/src/Controller/SwaggerController.php b/src/Controller/SwaggerController.php index 2219ad2d..f0defafa 100644 --- a/src/Controller/SwaggerController.php +++ b/src/Controller/SwaggerController.php @@ -6,9 +6,13 @@ use Cake\Event\EventInterface; use SwaggerBake\Lib\Configuration; use SwaggerBake\Lib\Factory\SwaggerFactory; +use SwaggerBake\Lib\Swagger; class SwaggerController extends AppController { + /** @var Swagger */ + private $swagger; + /** * @see https://book.cakephp.org/4/en/controllers.html#controller-callback-methods * @param EventInterface $event @@ -20,8 +24,8 @@ public function beforeFilter(EventInterface $event) if ($config->getHotReload()) { $output = $config->getJson(); - $swagger = (new SwaggerFactory())->create(); - $swagger->writeFile($output); + $this->swagger = (new SwaggerFactory())->create(); + $this->swagger->writeFile($output); } } @@ -32,6 +36,14 @@ public function beforeFilter(EventInterface $event) */ public function index() { + foreach ($this->swagger->getOperationsWithNoHttp20x() as $operation) { + if (!isset($this->Flash)) { + triggerWarning('Operation ' . $operation->getOperationId() . ' does not have a HTTP 20x response'); + continue; + } + $this->Flash->error('Operation ' . $operation->getOperationId() . ' does not have a HTTP 20x response'); + } + $config = new Configuration(); $title = $config->getTitleFromYml(); $url = $config->getWebPath(); diff --git a/src/Lib/AbstractParameter.php b/src/Lib/AbstractParameter.php deleted file mode 100644 index e2d86531..00000000 --- a/src/Lib/AbstractParameter.php +++ /dev/null @@ -1,64 +0,0 @@ -config = $config; - $this->route = $route; - - $this->actionName = $this->route->getAction(); - $this->className = $this->route->getController() . 'Controller'; - - $this->controller = $this->getControllerFromNamespaces($this->className); - $instance = new $this->controller; - - $this->reflectionClass = new ReflectionClass($instance); - $this->reflectionMethods = $this->reflectionClass->getMethods(); - - $this->reader = new AnnotationReader(); - } - - protected function getMethods() : array - { - return array_filter($this->reflectionMethods, function ($method) { - return $method->name == $this->actionName; - }); - } - - private function getControllerFromNamespaces(string $className) : ?string - { - $namespaces = $this->config->getNamespaces(); - - if (!isset($namespaces['controllers']) || !is_array($namespaces['controllers'])) { - throw new SwaggerBakeRunTimeException( - 'Invalid configuration, missing SwaggerBake.namespaces.controllers' - ); - } - - foreach ($namespaces['controllers'] as $namespace) { - $entity = $namespace . 'Controller\\' . $className; - if (class_exists($entity, true)) { - return $entity; - } - } - - return null; - } -} \ No newline at end of file diff --git a/src/Lib/Annotation/SwagEntityAttributeHandler.php b/src/Lib/Annotation/SwagEntityAttributeHandler.php deleted file mode 100644 index 86a2c871..00000000 --- a/src/Lib/Annotation/SwagEntityAttributeHandler.php +++ /dev/null @@ -1,24 +0,0 @@ -setName($annotation->name) - ->setDescription($annotation->description) - ->setType($annotation->type) - ->setReadOnly($annotation->readOnly) - ->setWriteOnly($annotation->writeOnly) - ->setRequired($annotation->required) - ; - } -} \ No newline at end of file diff --git a/src/Lib/Annotation/SwagFormHandler.php b/src/Lib/Annotation/SwagFormHandler.php deleted file mode 100644 index c69884a3..00000000 --- a/src/Lib/Annotation/SwagFormHandler.php +++ /dev/null @@ -1,22 +0,0 @@ -setDescription($annotation->description) - ->setName($annotation->name) - ->setType($annotation->type) - ->setRequired($annotation->required) - ; - } -} \ No newline at end of file diff --git a/src/Lib/Annotation/SwagHeaderHandler.php b/src/Lib/Annotation/SwagHeaderHandler.php deleted file mode 100644 index 1eb2f44c..00000000 --- a/src/Lib/Annotation/SwagHeaderHandler.php +++ /dev/null @@ -1,26 +0,0 @@ -setName($annotation->name) - ->setDescription($annotation->description) - ->setAllowEmptyValue(false) - ->setDeprecated(false) - ->setRequired($annotation->required) - ->setIn('header') - ->setSchema((new Schema())->setType($annotation->type)) - ; - } -} \ No newline at end of file diff --git a/src/Lib/Annotation/SwagPaginatorHandler.php b/src/Lib/Annotation/SwagPaginatorHandler.php deleted file mode 100644 index 02a55fe7..00000000 --- a/src/Lib/Annotation/SwagPaginatorHandler.php +++ /dev/null @@ -1,36 +0,0 @@ - 'integer', - 'limit' => 'integer', - 'sort' => 'string', - 'direction' => 'string' - ]; - - $parameter = (new Parameter()) - ->setAllowEmptyValue(false) - ->setDeprecated(false) - ->setRequired(false) - ->setIn('query'); - - $return = []; - foreach ($paginators as $name => $type) { - $param = clone $parameter; - $return[] = $param->setName($name)->setSchema((new Schema())->setType($type)); - } - return $return; - } -} \ No newline at end of file diff --git a/src/Lib/Annotation/SwagQueryHandler.php b/src/Lib/Annotation/SwagQueryHandler.php deleted file mode 100644 index 2bfb6ce9..00000000 --- a/src/Lib/Annotation/SwagQueryHandler.php +++ /dev/null @@ -1,22 +0,0 @@ -setName($annotation->name) - ->setDescription($annotation->description) - ->setAllowEmptyValue(false) - ->setDeprecated(false) - ->setRequired($annotation->required) - ->setIn('query') - ->setSchema((new Schema())->setType($annotation->type)) - ; - } -} \ No newline at end of file diff --git a/src/Lib/Annotation/SwagRequestBodyContentHandler.php b/src/Lib/Annotation/SwagRequestBodyContentHandler.php deleted file mode 100644 index 599c2f2d..00000000 --- a/src/Lib/Annotation/SwagRequestBodyContentHandler.php +++ /dev/null @@ -1,20 +0,0 @@ -setMimeType($annotation->mimeType) - ->setSchema($annotation->refEntity) - ; - } -} \ No newline at end of file diff --git a/src/Lib/Annotation/SwagRequestBodyHandler.php b/src/Lib/Annotation/SwagRequestBodyHandler.php deleted file mode 100644 index d86b97dd..00000000 --- a/src/Lib/Annotation/SwagRequestBodyHandler.php +++ /dev/null @@ -1,21 +0,0 @@ -setDescription($annotation->description) - ->setRequired((bool) $annotation->required) - ->setIgnoreCakeSchema((bool) $annotation) - ; - } -} \ No newline at end of file diff --git a/src/Lib/Annotation/SwagResponseSchemaHandler.php b/src/Lib/Annotation/SwagResponseSchemaHandler.php deleted file mode 100644 index 019a817c..00000000 --- a/src/Lib/Annotation/SwagResponseSchemaHandler.php +++ /dev/null @@ -1,32 +0,0 @@ -setCode(intval($annotation->httpCode)) - ->setDescription($annotation->description); - - if (empty($annotation->schemaFormat) && empty($annotation->mimeType)) { - return $response; - } - - return $response->pushContent( - (new Content()) - ->setSchema($annotation->refEntity) - ->setFormat($annotation->schemaFormat) - ->setType($annotation->schemaType) - ->setMimeType($annotation->mimeType) - ); - } -} \ No newline at end of file diff --git a/src/Lib/Annotation/SwagSecurityHandler.php b/src/Lib/Annotation/SwagSecurityHandler.php deleted file mode 100644 index f30dd349..00000000 --- a/src/Lib/Annotation/SwagSecurityHandler.php +++ /dev/null @@ -1,20 +0,0 @@ -setName($annotation->name) - ->setScopes($annotation->scopes) - ; - } -} \ No newline at end of file diff --git a/src/Lib/AnnotationLoader.php b/src/Lib/AnnotationLoader.php index 9f012cc6..96177cb2 100644 --- a/src/Lib/AnnotationLoader.php +++ b/src/Lib/AnnotationLoader.php @@ -5,6 +5,10 @@ use Doctrine\Common\Annotations\AnnotationRegistry; use SwaggerBake\Lib\Annotation as SwagAnnotation; +/** + * Class AnnotationLoader + * @package SwaggerBake\Lib + */ class AnnotationLoader { public static function load() : void diff --git a/src/Lib/CakeModel.php b/src/Lib/CakeModel.php index 45146506..96348c8e 100644 --- a/src/Lib/CakeModel.php +++ b/src/Lib/CakeModel.php @@ -6,12 +6,15 @@ use Cake\Datasource\EntityInterface; use Cake\Database\Schema\TableSchema; use Cake\Utility\Inflector; +use SwaggerBake\Lib\Annotation\SwagEntity; +use SwaggerBake\Lib\Decorator\EntityDecorator; +use SwaggerBake\Lib\Decorator\PropertyDecorator; use SwaggerBake\Lib\Exception\SwaggerBakeRunTimeException; -use SwaggerBake\Lib\Model\ExpressiveAttribute; -use SwaggerBake\Lib\Model\ExpressiveModel; +use SwaggerBake\Lib\Utility\AnnotationUtility; /** * Class CakeModel + * @package SwaggerBake\Lib */ class CakeModel { @@ -32,9 +35,9 @@ public function __construct(CakeRoute $cakeRoute, Configuration $config) } /** - * Gets an array of ExpressiveModel + * Gets an array of EntityDecorator * - * @return ExpressiveModel[] + * @return EntityDecorator[] */ public function getModels() : array { @@ -49,25 +52,23 @@ public function getModels() : array foreach ($tables as $tableName) { - if (!in_array($tableName, $tabularRoutes)) { - continue; - } - $className = Inflector::classify($tableName); $entity = $this->getEntityFromNamespaces($className); + if (empty($entity)) { continue; } + if (!in_array($tableName, $tabularRoutes) && !$this->entityHasVisibility($entity)) { + continue; + } + $entityInstance = new $entity; $schema = $collection->describe($tableName); - $attributes = $this->getExpressiveAttributes($entityInstance, $schema); - - $expressiveModel = new ExpressiveModel(); - $expressiveModel->setName($className)->setAttributes($attributes); + $properties = $this->getPropertyDecorators($entityInstance, $schema); - $return[] = $expressiveModel; + $return[] = (new EntityDecorator($entityInstance))->setProperties($properties); } return $return; @@ -140,9 +141,9 @@ private function getTablesFromRoutes(array $routes) : array /** * @param EntityInterface $entity * @param TableSchema $schema - * @return ExpressiveAttribute[] + * @return PropertyDecorator[] */ - private function getExpressiveAttributes(EntityInterface $entity, TableSchema $schema) : array + private function getPropertyDecorators(EntityInterface $entity, TableSchema $schema) : array { $return = []; @@ -157,14 +158,14 @@ private function getExpressiveAttributes(EntityInterface $entity, TableSchema $s $vars = $schema->__debugInfo(); $default = isset($vars['columns'][$columnName]['default']) ? $vars['columns'][$columnName]['default'] : ''; - $expressiveAttribute = new ExpressiveAttribute(); - $expressiveAttribute + $PropertyDecorator = new PropertyDecorator(); + $PropertyDecorator ->setName($columnName) ->setType($schema->getColumnType($columnName)) ->setDefault($default) ->setIsPrimaryKey($this->isPrimaryKey($vars, $columnName)) ; - $return[] = $expressiveAttribute; + $return[] = $PropertyDecorator; } return $return; @@ -183,4 +184,29 @@ private function isPrimaryKey(array $schemaDebugInfo, string $columnName) : bool return in_array($columnName, $schemaDebugInfo['constraints']['primary']['columns']); } + + /** + * @param string $fqns + * @return bool + */ + private function entityHasVisibility(string $fqns) : bool + { + if (empty($fqns)) { + return false; + } + + $annotations = AnnotationUtility::getClassAnnotationsFromFqns($fqns); + + $swagEntities = array_filter($annotations, function ($annotation) { + return $annotation instanceof SwagEntity; + }); + + if (empty($swagEntities)) { + return false; + } + + $swagEntity = reset($swagEntities); + + return $swagEntity->isVisible; + } } \ No newline at end of file diff --git a/src/Lib/CakeRoute.php b/src/Lib/CakeRoute.php index 23ccceab..37787856 100644 --- a/src/Lib/CakeRoute.php +++ b/src/Lib/CakeRoute.php @@ -2,14 +2,14 @@ namespace SwaggerBake\Lib; -use SwaggerBake\Lib\Model\ExpressiveRoute; +use SwaggerBake\Lib\Decorator\RouteDecorator; use Cake\Routing\Route\Route; use Cake\Routing\Router; use InvalidArgumentException; /** * Class CakeRoute - * Gets an array of routes matching a given route prefix + * @package SwaggerBake\Lib */ class CakeRoute { @@ -37,7 +37,7 @@ public function __construct(Router $router, Configuration $config) /** * Gets an array of Route * - * @return ExpressiveRoute[] + * @return RouteDecorator[] */ public function getRoutes() : array { @@ -52,7 +52,7 @@ public function getRoutes() : array $routes = []; foreach ($filteredRoutes as $route) { - $routes[$route->getName()] = $this->createExpressiveRouteFromRoute($route); + $routes[$route->getName()] = new RouteDecorator($route); } ksort($routes); @@ -60,29 +60,6 @@ public function getRoutes() : array return $routes; } - /** - * @param Route $route - * @return ExpressiveRoute - */ - private function createExpressiveRouteFromRoute(Route $route) : ExpressiveRoute - { - $defaults = (array) $route->defaults; - - $methods = $defaults['_method']; - if (!is_array($defaults['_method'])) { - $methods = explode(', ', $defaults['_method']); - } - - return (new ExpressiveRoute()) - ->setPlugin($defaults['plugin']) - ->setController($defaults['controller']) - ->setName($route->getName()) - ->setAction($defaults['action']) - ->setMethods($methods) - ->setTemplate($route->template) - ; - } - /** * @param Route $route * @return bool diff --git a/src/Lib/Configuration.php b/src/Lib/Configuration.php index 85af5c8c..c1034202 100644 --- a/src/Lib/Configuration.php +++ b/src/Lib/Configuration.php @@ -6,6 +6,10 @@ use LogicException; use Symfony\Component\Yaml\Yaml; +/** + * Class Configuration + * @package SwaggerBake\Lib + */ class Configuration { /** @var array */ diff --git a/src/Lib/Decorator/EntityDecorator.php b/src/Lib/Decorator/EntityDecorator.php new file mode 100644 index 00000000..81e003f5 --- /dev/null +++ b/src/Lib/Decorator/EntityDecorator.php @@ -0,0 +1,113 @@ +entity = $entity; + $this->fqns = get_class($entity); + + try { + $this->name = (new ReflectionClass($entity))->getShortName(); + } catch(ReflectionException $e) { + throw new SwaggerBakeRunTimeException('ReflectionException: ' . $e->getMessage()); + } + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + * @return EntityDecorator + */ + public function setName(string $name): EntityDecorator + { + $this->name = $name; + return $this; + } + + /** + * @return PropertyDecorator[] + */ + public function getProperties(): array + { + return $this->properties; + } + + /** + * @param PropertyDecorator[] $properties + * @return EntityDecorator + */ + public function setProperties(array $properties): EntityDecorator + { + $this->properties = $properties; + return $this; + } + + /** + * @return Entity + */ + public function getEntity(): Entity + { + return $this->entity; + } + + /** + * @param Entity $entity + * @return EntityDecorator + */ + public function setEntity(Entity $entity): EntityDecorator + { + $this->entity = $entity; + return $this; + } + + /** + * @return string + */ + public function getFqns(): string + { + return $this->fqns; + } + + /** + * @param string $fqns + * @return EntityDecorator + */ + public function setFqns(string $fqns): EntityDecorator + { + $this->fqns = $fqns; + return $this; + } +} \ No newline at end of file diff --git a/src/Lib/Model/ExpressiveAttribute.php b/src/Lib/Decorator/PropertyDecorator.php similarity index 67% rename from src/Lib/Model/ExpressiveAttribute.php rename to src/Lib/Decorator/PropertyDecorator.php index 569cdc04..a0eca4f7 100644 --- a/src/Lib/Model/ExpressiveAttribute.php +++ b/src/Lib/Decorator/PropertyDecorator.php @@ -1,10 +1,14 @@ name = $name; return $this; @@ -46,9 +50,9 @@ public function getType(): string /** * @param string $type - * @return ExpressiveAttribute + * @return PropertyDecorator */ - public function setType(string $type): ExpressiveAttribute + public function setType(string $type): PropertyDecorator { $this->type = $type; return $this; @@ -64,9 +68,9 @@ public function getDefault(): string /** * @param string $default - * @return ExpressiveAttribute + * @return PropertyDecorator */ - public function setDefault(string $default): ExpressiveAttribute + public function setDefault(string $default): PropertyDecorator { $this->default = $default; return $this; @@ -82,9 +86,9 @@ public function isPrimaryKey(): bool /** * @param bool $isPrimaryKey - * @return ExpressiveAttribute + * @return PropertyDecorator */ - public function setIsPrimaryKey(bool $isPrimaryKey): ExpressiveAttribute + public function setIsPrimaryKey(bool $isPrimaryKey): PropertyDecorator { $this->isPrimaryKey = $isPrimaryKey; return $this; diff --git a/src/Lib/Model/ExpressiveRoute.php b/src/Lib/Decorator/RouteDecorator.php similarity index 56% rename from src/Lib/Model/ExpressiveRoute.php rename to src/Lib/Decorator/RouteDecorator.php index f013d588..fd8d7ea8 100644 --- a/src/Lib/Model/ExpressiveRoute.php +++ b/src/Lib/Decorator/RouteDecorator.php @@ -1,11 +1,20 @@ route; + + $defaults = (array) $route->defaults; + + $methods = $defaults['_method']; + if (!is_array($defaults['_method'])) { + $methods = explode(', ', $defaults['_method']); + } + + $this + ->setTemplate($route->template) + ->setName($route->getName()) + ->setPlugin($defaults['plugin']) + ->setController($defaults['controller']) + ->setAction($defaults['action']) + ->setMethods($methods) + ; + } + + /** + * @return Route + */ + public function getRoute(): Route + { + return $this->route; + } + + /** + * @param Route $route + * @return RouteDecorator + */ + public function setRoute(Route $route): RouteDecorator + { + $this->route = $route; + return $this; + } + /** * @return string */ @@ -36,7 +84,7 @@ public function getName(): ?string * @param $name * @return $this */ - public function setName(string $name): ExpressiveRoute + public function setName(string $name): RouteDecorator { $this->name = $name; return $this; @@ -54,7 +102,7 @@ public function getPlugin(): ?string * @param string|null $plugin * @return $this */ - public function setPlugin(?string $plugin): ExpressiveRoute + public function setPlugin(?string $plugin): RouteDecorator { $this->plugin = $plugin; return $this; @@ -90,7 +138,7 @@ public function getAction(): ?string * @param $action * @return $this */ - public function setAction($action): ExpressiveRoute + public function setAction($action): RouteDecorator { $this->action = $action; return $this; @@ -108,9 +156,9 @@ public function getMethods(): array * @param array $methods * @return $this */ - public function setMethods(array $methods): ExpressiveRoute + public function setMethods(array $methods): RouteDecorator { - $this->methods = $methods; + $this->methods = array_map('strtoupper', $methods); return $this; } @@ -126,7 +174,7 @@ public function getTemplate() : ?string * @param $template * @return $this */ - public function setTemplate(string $template): ExpressiveRoute + public function setTemplate(string $template): RouteDecorator { $this->template = $template; return $this; diff --git a/src/Lib/Exception/SwaggerBakeRunTimeException.php b/src/Lib/Exception/SwaggerBakeRunTimeException.php index cea3a148..498a003d 100644 --- a/src/Lib/Exception/SwaggerBakeRunTimeException.php +++ b/src/Lib/Exception/SwaggerBakeRunTimeException.php @@ -5,6 +5,10 @@ use Cake\Core\Exception\Exception; +/** + * Class SwaggerBakeRunTimeException + * @package SwaggerBake\Lib\Exception + */ class SwaggerBakeRunTimeException extends Exception { diff --git a/src/Lib/Factory/PathFactory.php b/src/Lib/Factory/PathFactory.php deleted file mode 100644 index ecd8bc4e..00000000 --- a/src/Lib/Factory/PathFactory.php +++ /dev/null @@ -1,480 +0,0 @@ -config = $config; - $this->route = $route; - $this->prefix = $config->getPrefix(); - $this->dockBlock = $this->getDocBlock(); - } - - /** - * Creates a Path and returns it - * - * @return Path|null - */ - public function create() : ?Path - { - $path = new Path(); - - if (empty($this->route->getMethods())) { - return null; - } - - if (!$this->isControllerVisible($this->route->getController())) { - return null; - } - - foreach ($this->route->getMethods() as $method) { - - $methodAnnotations = $this->getMethodAnnotations( - $this->route->getController(), - $this->route->getAction() - ); - - if (!$this->isMethodVisible($methodAnnotations)) { - continue; - } - - $path - ->setType(strtolower($method)) - ->setPath($this->getPathName()) - ->setOperationId($this->route->getName()) - ->setSummary($this->dockBlock ? $this->dockBlock->getSummary() : '') - ->setDescription($this->dockBlock ? $this->dockBlock->getDescription() : '') - ->setTags([ - Inflector::humanize(Inflector::underscore($this->route->getController())) - ]) - ->setParameters($this->getPathParameters()) - ->setDeprecated($this->isDeprecated()) - ; - - $path = $this->withDataTransferObject($path, $methodAnnotations); - $path = $this->withResponses($path, $methodAnnotations); - $path = $this->withRequestBody($path, $methodAnnotations); - $path = $this->withExternalDoc($path); - } - - return $path; - } - - /** - * Returns a route (e.g. /api/model/action) - * - * @return string - */ - private function getPathName() : string - { - $pieces = array_map( - function ($piece) { - if (substr($piece, 0, 1) == ':') { - return '{' . str_replace(':', '', $piece) . '}'; - } - return $piece; - }, - explode('/', $this->route->getTemplate()) - ); - - if ($this->prefix == '/') { - return implode('/', $pieces); - } - - return substr( - implode('/', $pieces), - strlen($this->prefix) - ); - } - - /** - * Returns an array of Parameter - * - * @return Parameter[] - */ - private function getPathParameters() : array - { - $return = []; - - $pieces = explode('/', $this->route->getTemplate()); - $results = array_filter($pieces, function ($piece) { - return substr($piece, 0, 1) == ':' ? true : null; - }); - - if (empty($results)) { - return $return; - } - - foreach ($results as $result) { - - $schema = new Schema(); - $schema - ->setType('string') - ; - - $name = strtolower($result); - - if (substr($name, 0, 1) == ':') { - $name = substr($name, 1); - } - - $parameter = new Parameter(); - $parameter - ->setName($name) - ->setAllowEmptyValue(false) - ->setDeprecated(false) - ->setRequired(true) - ->setIn('path') - ->setSchema($schema) - ; - $return[] = $parameter; - } - - return $return; - } - - /** - * Returns an array of Lib/Annotation objects that can be applied to methods - * - * @param string $className - * @param string $method - * @return array - */ - private function getMethodAnnotations(string $className, string $method) : array - { - $className = $className . 'Controller'; - $controller = $this->getControllerFromNamespaces($className); - return AnnotationUtility::getMethodAnnotations($controller, $method); - } - - /** - * Returns a Path after applying Data Transfer Objects from annotations argument - * - * @param Path $path - * @param array $annotations - * @return Path - * @throws \ReflectionException - */ - private function withDataTransferObject(Path $path, array $annotations) : Path - { - if (empty($annotations)) { - return $path; - } - - $dataTransferObjects = array_filter($annotations, function ($annotation) { - return $annotation instanceof SwagAnnotation\SwagDto; - }); - - if (empty($dataTransferObjects)) { - return $path; - } - - $dto = reset($dataTransferObjects); - $class = $dto->class; - - if (!class_exists($class)) { - return $path; - } - - $instance = (new ReflectionClass($class))->newInstanceWithoutConstructor(); - $properties = DocBlockUtility::getProperties($instance); - - if (empty($properties)) { - return $path; - } - - $filteredProperties = array_filter($properties, function ($property) use ($instance) { - if (!isset($property->class) || $property->class != get_class($instance)) { - return null; - } - return true; - }); - - $pathType = strtolower($path->getType()); - if ($pathType == 'post') { - $requestBody = new RequestBody(); - $schema = (new Schema())->setType('object'); - } - - foreach ($filteredProperties as $name => $reflectionProperty) { - $docBlock = DocBlockUtility::getPropertyDocBlock($reflectionProperty); - $vars = $docBlock->getTagsByName('var'); - if (empty($vars)) { - throw new LogicException('@var must be set for ' . $class . '::' . $name); - } - $var = reset($vars); - $dataType = DocBlockUtility::getDocBlockConvertedVar($var); - - if ($pathType == 'get') { - $path->pushParameter( - (new Parameter()) - ->setName($name) - ->setIn('query') - ->setRequired(!empty($docBlock->getTagsByName('required'))) - ->setDescription($docBlock->getSummary()) - ->setSchema((new Schema())->setType($dataType)) - ); - } else if ($pathType == 'post' && isset($schema)) { - $schema->pushProperty( - (new SchemaProperty()) - ->setDescription($docBlock->getSummary()) - ->setName($name) - ->setType($dataType) - ->setRequired(!empty($docBlock->getTagsByName('required'))) - ); - } - } - - if (isset($schema) && isset($requestBody)) { - $content = (new Content()) - ->setMimeType('application/x-www-form-urlencoded') - ->setSchema($schema); - - $path->setRequestBody( - $requestBody->pushContent($content) - ); - } - - return $path; - } - - /** - * Returns path with responses - * - * @param Path $path - * @param array $annotations - * @return Path - */ - private function withResponses(Path $path, array $annotations) : Path - { - if (!empty($annotations)) { - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagAnnotation\SwagResponseSchema) { - $path->pushResponse((new SwagAnnotation\SwagResponseSchemaHandler())->getResponse($annotation)); - } - } - } - - if (!$this->dockBlock || !$this->dockBlock->hasTag('throws')) { - return $path; - } - - $throws = $this->dockBlock->getTagsByName('throws'); - - foreach ($throws as $throw) { - $exception = new ExceptionHandler($throw->getType()->__toString()); - $path->pushResponse( - (new Response())->setCode($exception->getCode())->setDescription($exception->getMessage()) - ); - } - - return $path; - } - - /** - * Returns Path with request body - * - * @param Path $path - * @param array $annotations - * @return Path - */ - private function withRequestBody(Path $path, array $annotations) : Path - { - if (!empty($path->getRequestBody())) { - return $path; - } - - if (empty($annotations)) { - return $path; - } - - $requestBody = new RequestBody(); - - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagAnnotation\SwagRequestBody) { - $requestBody = (new SwagAnnotation\SwagRequestBodyHandler())->getResponse($annotation); - } - } - - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagAnnotation\SwagRequestBodyContent) { - $requestBody->pushContent( - (new SwagAnnotation\SwagRequestBodyContentHandler())->getContent($annotation) - ); - } - } - - if (empty($requestBody->getContent())) { - return $path->setRequestBody($requestBody); - } - - return $path->setRequestBody($requestBody); - } - - /** - * @return DocBlock|null - */ - private function getDocBlock() : ?DocBlock - { - if (empty($this->route->getController())) { - return null; - } - - $className = $this->route->getController() . 'Controller'; - $methodName = $this->route->getAction(); - $controller = $this->getControllerFromNamespaces($className); - - if (!class_exists($controller)) { - return null; - } - - try { - return DocBlockUtility::getMethodDocBlock(new $controller, $methodName); - } catch (Exception $e) { - return null; - } - } - - /** - * @param string $className - * @return string|null - */ - private function getControllerFromNamespaces(string $className) : ?string - { - $namespaces = $this->config->getNamespaces(); - - if (!isset($namespaces['controllers']) || !is_array($namespaces['controllers'])) { - throw new SwaggerBakeRunTimeException( - 'Invalid configuration, missing SwaggerBake.namespaces.controllers' - ); - } - - foreach ($namespaces['controllers'] as $namespace) { - $entity = $namespace . 'Controller\\' . $className; - if (class_exists($entity, true)) { - return $entity; - } - } - - return null; - } - - /** - * @param string $className - * @return bool - */ - private function isControllerVisible(string $className) : bool - { - $className = $className . 'Controller'; - $controller = $this->getControllerFromNamespaces($className); - - if (!$controller) { - return false; - } - - $annotations = AnnotationUtility::getClassAnnotations($controller); - - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagAnnotation\SwagPath) { - return $annotation->isVisible; - } - } - - return true; - } - - /** - * @param array $annotations - * @return bool - */ - private function isMethodVisible(array $annotations) : bool - { - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagAnnotation\SwagOperation) { - return $annotation->isVisible; - } - } - - return true; - } - - /** - * Check if this path/operation is deprecated - * - * @return bool - */ - private function isDeprecated() : bool - { - if (!$this->dockBlock || !$this->dockBlock instanceof DocBlock) { - return false; - } - - return $this->dockBlock->hasTag('deprecated'); - } - - /** - * Defines external documentation using see tag - * @param Path $path - * @return Path - */ - private function withExternalDoc(Path $path) : Path - { - if (!$this->dockBlock || !$this->dockBlock instanceof DocBlock) { - return $path; - } - - if (!$this->dockBlock->hasTag('see')) { - return $path; - } - - $tags = $this->dockBlock->getTagsByName('see'); - $seeTag = reset($tags); - $str = $seeTag->__toString(); - $pieces = explode(' ', $str); - - if (!filter_var($pieces[0], FILTER_VALIDATE_URL)) { - return $path; - } - - $externalDoc = new OperationExternalDoc(); - $externalDoc->setUrl($pieces[0]); - - array_shift($pieces); - - if (!empty($pieces)) { - $externalDoc->setDescription(implode(' ', $pieces)); - } - - return $path->setExternalDocs($externalDoc); - } -} diff --git a/src/Lib/Factory/SchemaFactory.php b/src/Lib/Factory/SchemaFactory.php index 9bb1fa32..0bdfb0ef 100644 --- a/src/Lib/Factory/SchemaFactory.php +++ b/src/Lib/Factory/SchemaFactory.php @@ -9,16 +9,21 @@ use ReflectionClass; use SwaggerBake\Lib\Annotation\SwagEntity; use SwaggerBake\Lib\Annotation\SwagEntityAttribute; -use SwaggerBake\Lib\Annotation\SwagEntityAttributeHandler; use SwaggerBake\Lib\Configuration; use SwaggerBake\Lib\Exception\SwaggerBakeRunTimeException; -use SwaggerBake\Lib\Model\ExpressiveAttribute; -use SwaggerBake\Lib\Model\ExpressiveModel; +use SwaggerBake\Lib\Decorator\PropertyDecorator; +use SwaggerBake\Lib\Decorator\EntityDecorator; use SwaggerBake\Lib\OpenApi\Schema; use SwaggerBake\Lib\OpenApi\SchemaProperty; use SwaggerBake\Lib\Utility\AnnotationUtility; use SwaggerBake\Lib\Utility\DataTypeConversion; +/** + * Class SchemaFactory + * @package SwaggerBake\Lib\Factory + * + * Creates an instance of SwaggerBake\Lib\OpenApi\Schema per OpenAPI specifications + */ class SchemaFactory { /** @var string[] */ @@ -36,24 +41,24 @@ public function __construct(Configuration $config) } /** - * @param ExpressiveModel $model + * @param EntityDecorator $entity * @return Schema|null */ - public function create(ExpressiveModel $model) : ?Schema + public function create(EntityDecorator $entity) : ?Schema { - if (!$this->isSwaggable($model)) { + if (!$this->isSwaggable($entity)) { return null; } - $this->validator = $this->getValidator($model->getName()); + $this->validator = $this->getValidator($entity->getName()); - $docBlock = $this->getDocBlock($model); + $docBlock = $this->getDocBlock($entity); - $properties = $this->getProperties($model); + $properties = $this->getProperties($entity); $schema = new Schema(); $schema - ->setName($model->getName()) + ->setName($entity->getName()) ->setDescription($docBlock ? $docBlock->getSummary() : '') ->setType('object') ->setProperties($properties) @@ -71,14 +76,14 @@ public function create(ExpressiveModel $model) : ?Schema } /** - * @param ExpressiveModel $model + * @param EntityDecorator $entity * @return array */ - private function getProperties(ExpressiveModel $model) : array + private function getProperties(EntityDecorator $entity) : array { - $return = $this->getSwagPropertyAnnotations($model); + $return = $this->getSwagPropertyAnnotations($entity); - foreach ($model->getAttributes() as $attribute) { + foreach ($entity->getProperties() as $attribute) { $name = $attribute->getName(); if (isset($return[$name])) { continue; @@ -91,15 +96,13 @@ private function getProperties(ExpressiveModel $model) : array } /** - * @param ExpressiveModel $model + * @param EntityDecorator $entity * @return DocBlock|null */ - private function getDocBlock(ExpressiveModel $model) : ?DocBlock + private function getDocBlock(EntityDecorator $entity) : ?DocBlock { - $entity = $this->getEntityFromNamespaces($model->getName()); - try { - $instance = new $entity; + $instance = $entity->getEntity(); $reflectionClass = new ReflectionClass(get_class($instance)); } catch (\Exception $e) { return null; @@ -115,30 +118,6 @@ private function getDocBlock(ExpressiveModel $model) : ?DocBlock return $docFactory->create($comments); } - /** - * @param string $className - * @return string|null - */ - private function getEntityFromNamespaces(string $className) : ?string - { - $namespaces = $this->config->getNamespaces(); - - if (!isset($namespaces['entities']) || !is_array($namespaces['entities'])) { - throw new SwaggerBakeRunTimeException( - 'Invalid configuration, missing SwaggerBake.namespaces.entities' - ); - } - - foreach ($namespaces['entities'] as $namespace) { - $entity = $namespace . 'Model\Entity\\' . $className; - if (class_exists($entity, true)) { - return $entity; - } - } - - return null; - } - /** * @param string $className * @return string|null @@ -164,56 +143,62 @@ private function getTableFromNamespaces(string $className) : ?string } /** - * @param ExpressiveAttribute $attribute + * @param PropertyDecorator $property * @return SchemaProperty */ - private function getSchemaProperty(ExpressiveAttribute $attribute) : SchemaProperty + private function getSchemaProperty(PropertyDecorator $property) : SchemaProperty { - $isReadOnlyField = in_array($attribute->getName(), self::READ_ONLY_FIELDS); - $isDateTimeField = in_array($attribute->getType(), self::DATETIME_TYPES); - - $property = new SchemaProperty(); - $property - ->setName($attribute->getName()) - ->setType(DataTypeConversion::convert($attribute->getType())) - ->setReadOnly(($attribute->isPrimaryKey() || ($isReadOnlyField && $isDateTimeField))) - ->setRequired($this->isAttributeRequired($attribute)) + $isReadOnlyField = in_array($property->getName(), self::READ_ONLY_FIELDS); + $isDateTimeField = in_array($property->getType(), self::DATETIME_TYPES); + + $schemaProperty = new SchemaProperty(); + $schemaProperty + ->setName($property->getName()) + ->setType(DataTypeConversion::convert($property->getType())) + ->setReadOnly(($property->isPrimaryKey() || ($isReadOnlyField && $isDateTimeField))) + ->setRequired($this->isAttributeRequired($property)) ; - return $property; + return $schemaProperty; } /** * Returns key-value pair of property name => SchemaProperty * - * @param ExpressiveModel $model + * @param EntityDecorator $entity * @return SchemaProperty[] */ - private function getSwagPropertyAnnotations(ExpressiveModel $model) : array + private function getSwagPropertyAnnotations(EntityDecorator $entity) : array { $return = []; - $entity = $this->getEntityFromNamespaces($model->getName()); - $annotations = AnnotationUtility::getClassAnnotations($entity); + $annotations = AnnotationUtility::getClassAnnotationsFromInstance($entity->getEntity()); - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagEntityAttribute) { - $schemaProperty = (new SwagEntityAttributeHandler())->getSchemaProperty($annotation); - $return[$schemaProperty->getName()] = $schemaProperty; - } - } + $swagEntityAttributes = array_filter($annotations, function ($annotation) { + return $annotation instanceof SwagEntityAttribute; + }); + + foreach ($swagEntityAttributes as $swagEntityAttribute) { + $return[$swagEntityAttribute->name] = (new SchemaProperty()) + ->setName($swagEntityAttribute->name) + ->setDescription($swagEntityAttribute->description) + ->setType($swagEntityAttribute->type) + ->setReadOnly($swagEntityAttribute->readOnly) + ->setWriteOnly($swagEntityAttribute->writeOnly) + ->setRequired($swagEntityAttribute->required) + ; + } return $return; } /** - * @param ExpressiveModel $model + * @param EntityDecorator $entity * @return bool */ - private function isSwaggable(ExpressiveModel $model) : bool + private function isSwaggable(EntityDecorator $entity) : bool { - $entity = $this->getEntityFromNamespaces($model->getName()); - $annotations = AnnotationUtility::getClassAnnotations($entity); + $annotations = AnnotationUtility::getClassAnnotationsFromInstance($entity->getEntity()); foreach ($annotations as $annotation) { if ($annotation instanceof SwagEntity) { @@ -225,16 +210,16 @@ private function isSwaggable(ExpressiveModel $model) : bool } /** - * @param ExpressiveAttribute $attribute + * @param PropertyDecorator $property * @return bool */ - private function isAttributeRequired(ExpressiveAttribute $attribute) : bool + private function isAttributeRequired(PropertyDecorator $property) : bool { if (!$this->validator) { return false; } - $validationSet = $this->validator->field($attribute->getName()); + $validationSet = $this->validator->field($property->getName()); if (!$validationSet->isEmptyAllowed()) { return true; } diff --git a/src/Lib/Factory/SwaggerFactory.php b/src/Lib/Factory/SwaggerFactory.php index f3aa7204..6bb2f307 100644 --- a/src/Lib/Factory/SwaggerFactory.php +++ b/src/Lib/Factory/SwaggerFactory.php @@ -1,9 +1,7 @@ getMethods() as $method) { - $annotations = $this->reader->getMethodAnnotations($method); - if (empty($annotations)) { - continue; - } - - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagAnnotation\SwagForm) { - $return = array_merge( - $return, - [ - (new SwagAnnotation\SwagFormHandler())->getSchemaProperty($annotation) - ] - ); - } - } - } - - return $return; - } -} \ No newline at end of file diff --git a/src/Lib/HeaderParameter.php b/src/Lib/HeaderParameter.php deleted file mode 100644 index dc8cd0eb..00000000 --- a/src/Lib/HeaderParameter.php +++ /dev/null @@ -1,34 +0,0 @@ -getMethods() as $method) { - $annotations = $this->reader->getMethodAnnotations($method); - if (empty($annotations)) { - continue; - } - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagAnnotation\SwagHeader) { - $return = array_merge( - $return, - [(new SwagAnnotation\SwagHeaderHandler())->getHeaderParameters($annotation)] - ); - } - } - } - - return $return; - } -} \ No newline at end of file diff --git a/src/Lib/Model/ExpressiveModel.php b/src/Lib/Model/ExpressiveModel.php deleted file mode 100644 index beb48d5e..00000000 --- a/src/Lib/Model/ExpressiveModel.php +++ /dev/null @@ -1,50 +0,0 @@ -name; - } - - /** - * @param string $name - * @return ExpressiveModel - */ - public function setName(string $name): ExpressiveModel - { - $this->name = $name; - return $this; - } - - /** - * @return array - */ - public function getAttributes(): array - { - return $this->attributes; - } - - /** - * @param array $attributes - * @return ExpressiveModel - */ - public function setAttributes(array $attributes): ExpressiveModel - { - $this->attributes = $attributes; - return $this; - } -} \ No newline at end of file diff --git a/src/Lib/OpenApi/Content.php b/src/Lib/OpenApi/Content.php index 79a0e12c..53afc373 100644 --- a/src/Lib/OpenApi/Content.php +++ b/src/Lib/OpenApi/Content.php @@ -1,10 +1,14 @@ httpMethod, ['GET', 'DELETE']) || empty($vars['requestBody'])) { + unset($vars['requestBody']); + } + if (empty($vars['security'])) { + unset($vars['security']); + } + if (empty($vars['externalDocs'])) { + unset($vars['externalDocs']); + } + + return $vars; + } + + /** + * @return array|mixed + */ + public function jsonSerialize() + { + return $this->toArray(); + } + + /** + * @return bool + */ + public function hasSuccessResponseCode() : bool + { + $results = array_filter($this->getResponses(), function ($response) { + return ($response->getCode() >= 200 && $response->getCode() < 300); + }); + + return count($results) > 0; + } + + /** + * Gets httpMethod as UPPERCASE string + * @return string + */ + public function getHttpMethod(): string + { + return strtoupper($this->httpMethod); + } + + /** + * @param string $httpMethod + * @return Operation + */ + public function setHttpMethod(string $httpMethod): Operation + { + $httpMethod = strtoupper($httpMethod); + if (!in_array($httpMethod, ['GET','PUT', 'POST', 'PATCH', 'DELETE'])) { + throw new InvalidArgumentException("Invalid HTTP METHOD: $httpMethod"); + } + + $this->httpMethod = $httpMethod; + return $this; + } + + /** + * @return array + */ + public function getTags(): array + { + return $this->tags; + } + + /** + * @param array $tags + * @return Operation + */ + public function setTags(array $tags): Operation + { + $this->tags = $tags; + return $this; + } + + /** + * @return string + */ + public function getOperationId(): string + { + return $this->operationId; + } + + /** + * @param string $operationId + * @return Operation + */ + public function setOperationId(string $operationId): Operation + { + $this->operationId = $operationId; + return $this; + } + + /** + * @return array + */ + public function getParameters(): array + { + return $this->parameters; + } + + /** + * @param array $parameters + * @return Operation + */ + public function setParameters(array $parameters): Operation + { + $this->parameters = $parameters; + return $this; + } + + /** + * @param Parameter $parameter + * @return Operation + */ + public function pushParameter(Parameter $parameter): Operation + { + $this->parameters[] = $parameter; + return $this; + } + + /** + * @return RequestBody|null + */ + public function getRequestBody() : ?RequestBody + { + return $this->requestBody; + } + + /** + * @param RequestBody $requestBody + * @return Operation + */ + public function setRequestBody(RequestBody $requestBody) : Operation + { + $this->requestBody = $requestBody; + return $this; + } + + /** + * @return Response[] + */ + public function getResponses(): array + { + return $this->responses; + } + + /** + * @param int $code + * @return Response|null + */ + public function getResponseByCode(int $code) : ?Response + { + return isset($this->responses[$code]) ? $this->responses[$code] : null; + } + + /** + * @param array $array + * @return Operation + */ + public function setResponses(array $array) : Operation + { + $this->responses = $array; + return $this; + } + + /** + * @param Response $response + * @return Operation + */ + public function pushResponse(Response $response): Operation + { + $code = $response->getCode(); + $existingResponse = $this->getResponseByCode($response->getCode()); + if ($this->getResponseByCode($response->getCode())) { + $content = $existingResponse->getContent() + $response->getContent(); + $existingResponse->setContent($content); + $this->responses[$code] = $existingResponse; + return $this; + } + $this->responses[$code] = $response; + return $this; + } + + /** + * @return array + */ + public function getSecurity(): array + { + return $this->security; + } + + /** + * @param array $security + * @return Operation + */ + public function setSecurity(array $security): Operation + { + $this->security = $security; + return $this; + } + + /** + * @param PathSecurity $security + * @return Operation + */ + public function pushSecurity(PathSecurity $security): Operation + { + $this->security[] = $security; + return $this; + } + + /** + * @return bool + */ + public function isDeprecated(): bool + { + return $this->deprecated; + } + + /** + * @param bool $deprecated + * @return Operation + */ + public function setDeprecated(bool $deprecated): Operation + { + $this->deprecated = $deprecated; + return $this; + } + + /** + * @return OperationExternalDoc + */ + public function getExternalDocs() : OperationExternalDoc + { + return $this->externalDocs; + } + + /** + * @param OperationExternalDoc $externalDoc + * @return Operation + */ + public function setExternalDocs(OperationExternalDoc $externalDoc) : Operation + { + $this->externalDocs = $externalDoc; + return $this; + } + + /** + * @return string + */ + public function getSummary(): string + { + return $this->summary; + } + + /** + * @param string $summary + * @return Operation + */ + public function setSummary(string $summary): Operation + { + $this->summary = $summary; + return $this; + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * @param string $description + * @return Operation + */ + public function setDescription(string $description): Operation + { + $this->description = $description; + return $this; + } +} diff --git a/src/Lib/OpenApi/OperationExternalDoc.php b/src/Lib/OpenApi/OperationExternalDoc.php index decf2743..8f377073 100644 --- a/src/Lib/OpenApi/OperationExternalDoc.php +++ b/src/Lib/OpenApi/OperationExternalDoc.php @@ -4,6 +4,11 @@ use JsonSerializable; +/** + * Class OperationExternalDoc + * @package SwaggerBake\Lib\OpenApi + * @see https://swagger.io/docs/specification/paths-and-operations/ + */ class OperationExternalDoc implements JsonSerializable { /** @var string */ diff --git a/src/Lib/OpenApi/Parameter.php b/src/Lib/OpenApi/Parameter.php index 2af27641..5a6f9668 100644 --- a/src/Lib/OpenApi/Parameter.php +++ b/src/Lib/OpenApi/Parameter.php @@ -9,7 +9,8 @@ /** * Class Parameter - * @see https://swagger.io/specification/ + * @package SwaggerBake\Lib\OpenApi + * @see https://swagger.io/docs/specification/describing-parameters/ */ class Parameter implements JsonSerializable { diff --git a/src/Lib/OpenApi/Path.php b/src/Lib/OpenApi/Path.php index 1a957f1e..6c0d8a4b 100644 --- a/src/Lib/OpenApi/Path.php +++ b/src/Lib/OpenApi/Path.php @@ -2,344 +2,94 @@ namespace SwaggerBake\Lib\OpenApi; -use InvalidArgumentException; +use JsonSerializable; /** * Class Path - * @todo implement $ref - * @see https://swagger.io/specification/ + * @package SwaggerBake\Lib\OpenApi + * @see https://swagger.io/docs/specification/paths-and-operations/ */ -class Path +class Path implements JsonSerializable { - /** @var string */ - private $summary = ''; - - /** @var string */ - private $description = ''; - - /** @var OperationExternalDoc|null */ - private $externalDocs; - - /** @var string */ - private $type = ''; - - /** @var string */ - private $path = ''; - - /** @var string[] */ - private $tags = []; - - /** @var string */ - private $operationId = ''; - - /** @var Parameter[] */ - private $parameters = []; - - /** @var RequestBody|null */ - private $requestBody; - - /** @var Response[] */ - private $responses = []; - - /** @var PathSecurity[] */ - private $security = []; - - /** @var bool */ - private $deprecated = false; - - public function toArray(): array - { - $vars = get_object_vars($this); - unset($vars['type']); - unset($vars['path']); - - if (in_array($this->type, ['get', 'delete'])) { - unset($vars['requestBody']); - } - if (empty($vars['security'])) { - unset($vars['security']); - } - if (empty($vars['externalDocs'])) { - unset($vars['externalDocs']); - } - - return $vars; - } - - public function hasSuccessResponseCode() : bool - { - $results = array_filter($this->getResponses(), function ($response) { - return ($response->getCode() >= 200 && $response->getCode() < 300); - }); - - return count($results) > 0; - } - - /** - * @return string - */ - public function getSummary(): string - { - return $this->summary; - } - /** - * @param string $summary - * @return Path + * The endpoint (resource) for the path + * @var string */ - public function setSummary(string $summary): Path - { - $this->summary = $summary; - return $this; - } + private $resource = ''; /** - * @return string + * @var Operation[] */ - public function getDescription(): string - { - return $this->description; - } + private $operations = []; - /** - * @param string $description - * @return Path - */ - public function setDescription(string $description): Path - { - $this->description = $description; - return $this; - } - - /** - * @return string - */ - public function getType(): string + public function toArray(): array { - return $this->type; - } + $vars = get_object_vars($this); + unset($vars['resource']); + unset($vars['operations']); - /** - * @param string $type - * @return Path - */ - public function setType(string $type): Path - { - $type = strtolower($type); - if (!in_array($type, ['get','put', 'post', 'patch', 'delete'])) { - throw new InvalidArgumentException("type must be a valid HTTP METHOD, $type given"); + foreach ($this->getOperations() as $operation) { + $vars[strtolower($operation->getHttpMethod())] = $operation; } - $this->type = $type; - return $this; - } - - /** - * @return array - */ - public function getTags(): array - { - return $this->tags; + return $vars; } /** - * @param array $tags - * @return Path + * @return array|mixed */ - public function setTags(array $tags): Path + public function jsonSerialize() { - $this->tags = $tags; - return $this; + return $this->toArray(); } /** * @return string */ - public function getOperationId(): string - { - return $this->operationId; - } - - /** - * @param string $operationId - * @return Path - */ - public function setOperationId(string $operationId): Path - { - $this->operationId = $operationId; - return $this; - } - - /** - * @return array - */ - public function getParameters(): array - { - return $this->parameters; - } - - /** - * @param array $parameters - * @return Path - */ - public function setParameters(array $parameters): Path - { - $this->parameters = $parameters; - return $this; - } - - /** - * @param Parameter $parameter - * @return Path - */ - public function pushParameter(Parameter $parameter): Path - { - $this->parameters[] = $parameter; - return $this; - } - - /** - * @return RequestBody|null - */ - public function getRequestBody() : ?RequestBody + public function getResource(): string { - return $this->requestBody; + return $this->resource; } /** - * @param RequestBody $requestBody + * @param string $resource * @return Path */ - public function setRequestBody(RequestBody $requestBody) : Path + public function setResource(string $resource): Path { - $this->requestBody = $requestBody; + $this->resource = $resource; return $this; } /** - * @return Response[] - */ - public function getResponses(): array - { - return $this->responses; - } - - /** - * @param int $code - * @return Response|null - */ - public function getResponseByCode(int $code) : ?Response - { - return isset($this->responses[$code]) ? $this->responses[$code] : null; - } - - /** - * @param array $array - * @return Path + * @return Operation[] */ - public function setResponses(array $array) : Path + public function getOperations(): array { - $this->responses = $array; - return $this; + return $this->operations; } /** - * @param Response $response + * @param Operation[] $operations * @return Path */ - public function pushResponse(Response $response): Path + public function setOperations(array $operations): Path { - $code = $response->getCode(); - $existingResponse = $this->getResponseByCode($response->getCode()); - if ($this->getResponseByCode($response->getCode())) { - $content = $existingResponse->getContent() + $response->getContent(); - $existingResponse->setContent($content); - $this->responses[$code] = $existingResponse; - return $this; + $this->operations = []; + foreach ($operations as $operation) { + $this->pushOperation($operation); } - $this->responses[$code] = $response; return $this; } /** - * @return array - */ - public function getSecurity(): array - { - return $this->security; - } - - /** - * @param array $security - * @return Path - */ - public function setSecurity(array $security): Path - { - $this->security = $security; - return $this; - } - - /** - * @param PathSecurity $security + * @param Operation $operation * @return $this */ - public function pushSecurity(PathSecurity $security): Path - { - $this->security[] = $security; - return $this; - } - - /** - * @return bool - */ - public function isDeprecated(): bool - { - return $this->deprecated; - } - - /** - * @param bool $deprecated - * @return Path - */ - public function setDeprecated(bool $deprecated): Path - { - $this->deprecated = $deprecated; - return $this; - } - - /** - * @return string - */ - public function getPath(): string - { - return $this->path; - } - - /** - * @param string $path - * @return Path - */ - public function setPath(string $path): Path - { - $this->path = $path; - return $this; - } - - /** - * @return OperationExternalDoc - */ - public function getExternalDocs() : OperationExternalDoc - { - return $this->externalDocs; - } - - /** - * @param OperationExternalDoc $externalDoc - * @return Path - */ - public function setExternalDocs(OperationExternalDoc $externalDoc) : Path + public function pushOperation(Operation $operation) : Path { - $this->externalDocs = $externalDoc; + $httpMethod = strtolower($operation->getHttpMethod()); + $this->operations[$httpMethod] = $operation; return $this; } } diff --git a/src/Lib/OpenApi/PathSecurity.php b/src/Lib/OpenApi/PathSecurity.php index 931d1be6..7511aa6f 100644 --- a/src/Lib/OpenApi/PathSecurity.php +++ b/src/Lib/OpenApi/PathSecurity.php @@ -4,6 +4,11 @@ use JsonSerializable; +/** + * Class PathSecurity + * @package SwaggerBake\Lib\OpenApi + * @see https://swagger.io/docs/specification/authentication/ + */ class PathSecurity implements JsonSerializable { /** @var string */ diff --git a/src/Lib/OpenApi/RequestBody.php b/src/Lib/OpenApi/RequestBody.php index 347576b3..b18b8d10 100644 --- a/src/Lib/OpenApi/RequestBody.php +++ b/src/Lib/OpenApi/RequestBody.php @@ -1,10 +1,14 @@ properties = $properties; + $this->properties = []; + foreach ($properties as $property) { + $this->pushProperty($property); + } + return $this; } diff --git a/src/Lib/OpenApi/SchemaProperty.php b/src/Lib/OpenApi/SchemaProperty.php index ef0cc1d9..2c0e8ec8 100644 --- a/src/Lib/OpenApi/SchemaProperty.php +++ b/src/Lib/OpenApi/SchemaProperty.php @@ -1,10 +1,14 @@ hasTag('deprecated')) { + $operation->setDeprecated(true); + } + + if (!$doc->hasTag('see')) { + return $operation; + } + + $tags = $doc->getTagsByName('see'); + $seeTag = reset($tags); + $str = $seeTag->__toString(); + $pieces = explode(' ', $str); + + if (!filter_var($pieces[0], FILTER_VALIDATE_URL)) { + return $operation; + } + + $externalDoc = new OperationExternalDoc(); + $externalDoc->setUrl($pieces[0]); + + array_shift($pieces); + + if (!empty($pieces)) { + $externalDoc->setDescription(implode(' ', $pieces)); + } + + return $operation->setExternalDocs($externalDoc); + } +} \ No newline at end of file diff --git a/src/Lib/Operation/OperationFromRouteFactory.php b/src/Lib/Operation/OperationFromRouteFactory.php new file mode 100644 index 00000000..7f50f440 --- /dev/null +++ b/src/Lib/Operation/OperationFromRouteFactory.php @@ -0,0 +1,129 @@ +config = $config; + } + + /** + * Creates an instance of Operation + * + * @param RouteDecorator $route + * @param string $httpMethod + * @param null|Schema $schema + * @return Operation|null + */ + public function create(RouteDecorator $route, string $httpMethod, ?Schema $schema) : ?Operation + { + if (empty($route->getMethods())) { + return null; + } + + $className = $route->getController() . 'Controller'; + $fullyQualifiedNameSpace = NamespaceUtility::getControllerFullQualifiedNameSpace($className, $this->config); + + $doc = $this->getDocBlock($fullyQualifiedNameSpace, $route->getAction()); + $methodAnnotations = AnnotationUtility::getMethodAnnotations($fullyQualifiedNameSpace, $route->getAction()); + + if (!$this->isVisible($methodAnnotations)) { + return null; + } + + $operation = (new Operation()) + ->setSummary($doc->getSummary()) + ->setDescription($doc->getDescription()) + ->setHttpMethod(strtolower($httpMethod)) + ->setOperationId($route->getName()) + ->setTags([ + Inflector::humanize(Inflector::underscore($route->getController())) + ]); + + $operation = (new OperationDocBlock()) + ->getOperationWithDocBlock($operation, $doc); + + $operation = (new OperationPath()) + ->getOperationWithPathParameters($operation, $route); + + $operation = (new OperationHeader()) + ->getOperationWithHeaders($operation, $methodAnnotations); + + $operation = (new OperationSecurity()) + ->getOperationWithSecurity($operation, $methodAnnotations); + + $operation = (new OperationQueryParameter()) + ->getOperationWithQueryParameters($operation, $methodAnnotations); + + $operation = (new OperationRequestBody($this->config, $operation, $doc, $methodAnnotations, $route, $schema)) + ->getOperationWithRequestBody(); + + $operation = (new OperationResponse($this->config, $operation, $doc, $methodAnnotations, $route, $schema)) + ->getOperationWithResponses(); + + return $operation; + } + + /** + * Gets an instance of DocBlock from the controllers method + * + * @param string $fullyQualifiedNameSpace + * @param string $methodName + * @return DocBlock + */ + private function getDocBlock(string $fullyQualifiedNameSpace, string $methodName) : DocBlock + { + $emptyDocBlock = DocBlockFactory::createInstance()->create('/** */'); + + if (!class_exists($fullyQualifiedNameSpace)) { + return $emptyDocBlock; + } + + try { + return DocBlockUtility::getMethodDocBlock(new $fullyQualifiedNameSpace, $methodName) ?? $emptyDocBlock; + } catch (Exception $e) { + return $emptyDocBlock; + } + } + + /** + * @param array $annotations + * @return bool + */ + private function isVisible(array $annotations) : bool + { + $swagOperations = array_filter($annotations, function ($annotation) { + return $annotation instanceof SwagOperation; + }); + + if (empty($swagOperations)) { + return true; + } + + $swagOperation = reset($swagOperations); + + return $swagOperation->isVisible === false ? false : true; + } +} \ No newline at end of file diff --git a/src/Lib/Operation/OperationFromYmlFactory.php b/src/Lib/Operation/OperationFromYmlFactory.php new file mode 100644 index 00000000..0887f4a1 --- /dev/null +++ b/src/Lib/Operation/OperationFromYmlFactory.php @@ -0,0 +1,48 @@ +setHttpMethod($httpMethod) + ->setTags(isset($var['tags']) ? $var['tags'] : []) + ->setOperationId(isset($var['operationId']) ? $var['operationId'] : '') + ->setDeprecated((bool) isset($var['deprecated']) ? $var['deprecated'] : false); + + if (isset($var['externalDocs']['url'])) { + $operation->setExternalDocs( + (new OperationExternalDoc()) + ->setDescription( + isset($var['externalDocs']['description']) ? $var['externalDocs']['description'] : '' + ) + ->setUrl($var['externalDocs']['url']) + ); + } + + if (isset($var['security']) && is_array($var['security'])) { + foreach ($var['security'] as $key => $scopes) { + $operation->pushSecurity((new PathSecurity())->setName($key)->setScopes($scopes)); + } + } + + return $operation; + } +} \ No newline at end of file diff --git a/src/Lib/Operation/OperationHeader.php b/src/Lib/Operation/OperationHeader.php new file mode 100644 index 00000000..58019b39 --- /dev/null +++ b/src/Lib/Operation/OperationHeader.php @@ -0,0 +1,43 @@ +setName($annotation->name) + ->setDescription($annotation->description) + ->setAllowEmptyValue(false) + ->setDeprecated(false) + ->setRequired($annotation->required) + ->setIn('header') + ->setSchema((new Schema())->setType($annotation->type)) + ; + + $operation->pushParameter($parameter); + } + + return $operation; + } +} \ No newline at end of file diff --git a/src/Lib/Operation/OperationPath.php b/src/Lib/Operation/OperationPath.php new file mode 100644 index 00000000..d98ed799 --- /dev/null +++ b/src/Lib/Operation/OperationPath.php @@ -0,0 +1,49 @@ +getTemplate()); + $results = array_filter($pieces, function ($piece) { + return substr($piece, 0, 1) == ':' ? true : null; + }); + + foreach ($results as $result) { + + $name = strtolower($result); + + if (substr($name, 0, 1) == ':') { + $name = substr($name, 1); + } + + $operation->pushParameter( + (new Parameter()) + ->setName($name) + ->setAllowEmptyValue(false) + ->setDeprecated(false) + ->setRequired(true) + ->setIn('path') + ->setSchema((new Schema())->setType('string')) + ); + } + + return $operation; + } +} \ No newline at end of file diff --git a/src/Lib/Operation/OperationQueryParameter.php b/src/Lib/Operation/OperationQueryParameter.php new file mode 100644 index 00000000..5cc60755 --- /dev/null +++ b/src/Lib/Operation/OperationQueryParameter.php @@ -0,0 +1,161 @@ +getHttpMethod() != 'GET') { + return $operation; + } + + $operation = $this->withSwagPaginator($operation, $annotations); + $operation = $this->withSwagQuery($operation, $annotations); + try { + $operation = $this->withSwagDto($operation, $annotations); + } catch (\ReflectionException $e) { + throw new SwaggerBakeRunTimeException('ReflectionException: ' . $e->getMessage()); + } + + return $operation; + } + + /** + * @param Operation $operation + * @param array $annotations + * @return Operation + */ + private function withSwagPaginator(Operation $operation, array $annotations) : Operation + { + $swagPaginator = array_filter($annotations, function ($annotation) { + return $annotation instanceof SwagPaginator; + }); + + if (empty($swagPaginator)) { + return $operation; + } + + $parameter = (new Parameter()) + ->setAllowEmptyValue(false) + ->setDeprecated(false) + ->setRequired(false) + ->setIn('query'); + + $params = ['page' => 'integer', 'limit' => 'integer', 'sort' => 'string', 'direction' => 'string']; + foreach ($params as $name => $type) { + $operation->pushParameter( + (clone $parameter)->setName($name)->setSchema((new Schema())->setType($type)) + ); + } + + return $operation; + } + + /** + * @param Operation $operation + * @param array $annotations + * @return Operation + */ + private function withSwagQuery(Operation $operation, array $annotations) : Operation + { + $swagQueries = array_filter($annotations, function ($annotation) { + return $annotation instanceof SwagQuery; + }); + + foreach ($swagQueries as $annotation) { + $parameter = (new Parameter()) + ->setName($annotation->name) + ->setDescription($annotation->description) + ->setAllowEmptyValue(false) + ->setDeprecated(false) + ->setRequired($annotation->required) + ->setIn('query') + ->setSchema((new Schema())->setType($annotation->type)) + ; + + $operation->pushParameter($parameter); + } + + return $operation; + } + + /** + * @param Operation $operation + * @param array $annotations + * @return Operation + * @throws \ReflectionException + */ + private function withSwagDto(Operation $operation, array $annotations) : Operation + { + $swagDtos = array_filter($annotations, function ($annotation) { + return $annotation instanceof SwagDto; + }); + + if (empty($swagDtos)) { + return $operation; + } + + $dto = reset($swagDtos); + $class = $dto->class; + + if (!class_exists($class)) { + return $operation; + } + + $instance = (new ReflectionClass($class))->newInstanceWithoutConstructor(); + $properties = DocBlockUtility::getProperties($instance); + + if (empty($properties)) { + return $operation; + } + + $filteredProperties = array_filter($properties, function ($property) use ($instance) { + if (!isset($property->class) || $property->class != get_class($instance)) { + return null; + } + return true; + }); + + foreach ($filteredProperties as $name => $reflectionProperty) { + $docBlock = DocBlockUtility::getPropertyDocBlock($reflectionProperty); + $vars = $docBlock->getTagsByName('var'); + if (empty($vars)) { + throw new LogicException('@var must be set for ' . $class . '::' . $name); + } + $var = reset($vars); + $dataType = DocBlockUtility::getDocBlockConvertedVar($var); + + $operation->pushParameter( + (new Parameter()) + ->setName($name) + ->setIn('query') + ->setRequired(!empty($docBlock->getTagsByName('required'))) + ->setDescription($docBlock->getSummary()) + ->setSchema((new Schema())->setType($dataType)) + ); + } + + return $operation; + } +} \ No newline at end of file diff --git a/src/Lib/Operation/OperationRequestBody.php b/src/Lib/Operation/OperationRequestBody.php new file mode 100644 index 00000000..d21f0926 --- /dev/null +++ b/src/Lib/Operation/OperationRequestBody.php @@ -0,0 +1,324 @@ +config = $config; + $this->operation = $operation; + $this->doc = $doc; + $this->annotations = $annotations; + $this->route = $route; + $this->schema = $schema; + } + + /** + * Gets an Operation with RequestBody + * + * @return Operation + */ + public function getOperationWithRequestBody() : Operation + { + if (!in_array($this->operation->getHttpMethod(), ['POST','PATCH','PUT'])) { + return $this->operation; + } + + $this->assignSwagRequestBodyAnnotation(); + $this->assignSwagRequestBodyContentAnnotations(); + $this->assignSwagFormAnnotations(); + $this->assignSwagDto(); + $this->assignSchema(); + + return $this->operation; + } + + /** + * Assigns @SwagRequestBody annotations + * + * @return void + */ + private function assignSwagRequestBodyAnnotation() : void + { + $swagRequestBodies = array_filter($this->annotations, function ($annotation) { + return $annotation instanceof SwagRequestBody; + }); + + if (empty($swagRequestBodies)) { + return; + } + + $swagRequestBody = reset($swagRequestBodies); + + $requestBody = $this->operation->getRequestBody() ?? new RequestBody(); + + $requestBody + ->setDescription($swagRequestBody->description) + ->setRequired($swagRequestBody->required) + ; + + $this->operation->setRequestBody($requestBody); + } + + /** + * Assigns @SwagRequestBodyContent annotations + * + * @return void + */ + private function assignSwagRequestBodyContentAnnotations() : void + { + $swagRequestBodyContents = array_filter($this->annotations, function ($annotation) { + return $annotation instanceof SwagRequestBodyContent; + }); + + if (empty($swagRequestBodyContents)) { + return; + } + + $swagRequestBodyContent = reset($swagRequestBodyContents); + + $requestBody = $this->operation->getRequestBody() ?? new RequestBody(); + + $requestBody->pushContent( + (new Content()) + ->setMimeType($swagRequestBodyContent->mimeType) + ->setSchema($swagRequestBodyContent->refEntity) + ); + + $this->operation->setRequestBody($requestBody); + } + + /** + * Adds @SwagForm annotations to the Operations Request Body + * + * @return void + */ + private function assignSwagFormAnnotations() : void + { + $swagForms = array_filter($this->annotations, function ($annotation) { + return $annotation instanceof SwagForm; + }); + + if (empty($swagForms)) { + return; + } + + $schema = (new Schema())->setType('object'); + + foreach ($swagForms as $annotation) { + $schema->pushProperty( + (new SchemaProperty()) + ->setDescription($annotation->description) + ->setName($annotation->name) + ->setType($annotation->type) + ->setRequired($annotation->required) + ); + } + + $requestBody = $this->operation->getRequestBody() ?? new RequestBody(); + + $requestBody->pushContent( + (new Content()) + ->setMimeType('application/x-www-form-urlencoded') + ->setSchema($schema) + ); + + $this->operation->setRequestBody($requestBody); + } + + /** + * Adds @SwagDto annotations to the Operations Request Body + * + * @return void + */ + private function assignSwagDto() : void + { + $swagDtos = array_filter($this->annotations, function ($annotation) { + return $annotation instanceof SwagDto; + }); + + if (empty($swagDtos)) { + return; + } + + $dto = reset($swagDtos); + $class = $dto->class; + + if (!class_exists($class)) { + return; + } + + try { + $instance = (new ReflectionClass($class))->newInstanceWithoutConstructor(); + $properties = DocBlockUtility::getProperties($instance); + } catch (ReflectionException $e) { + throw new SwaggerBakeRunTimeException('ReflectionException: ' . $e->getMessage()); + } + + if (empty($properties)) { + return; + } + + $filteredProperties = array_filter($properties, function ($property) use ($instance) { + if (!isset($property->class) || $property->class != get_class($instance)) { + return null; + } + return true; + }); + + if (empty($filteredProperties)) { + return; + } + + $requestBody = new RequestBody(); + $schema = (new Schema())->setType('object'); + + foreach ($filteredProperties as $name => $reflectionProperty) { + $docBlock = DocBlockUtility::getPropertyDocBlock($reflectionProperty); + $vars = $docBlock->getTagsByName('var'); + if (empty($vars)) { + throw new SwaggerBakeRunTimeException('@var must be set for ' . $class . '::' . $name); + } + $var = reset($vars); + $dataType = DocBlockUtility::getDocBlockConvertedVar($var); + + $schema->pushProperty( + (new SchemaProperty()) + ->setDescription($docBlock->getSummary()) + ->setName($name) + ->setType($dataType) + ->setRequired(!empty($docBlock->getTagsByName('required'))) + ); + } + + $this->operation->setRequestBody( + $requestBody->pushContent( + (new Content()) + ->setMimeType('application/x-www-form-urlencoded') + ->setSchema($schema) + ) + ); + } + + /** + * Adds Schema to the Operations Request Body + * + * @return void + */ + private function assignSchema() : void + { + if (!$this->schema) { + return; + } + + $requestBody = $this->operation->getRequestBody() ?? new RequestBody(); + + foreach ($this->config->getRequestAccepts() as $mimeType) { + + if ($mimeType === 'application/x-www-form-urlencoded') { + $requestBody = $this->getRequestBodyWithFormSchema($requestBody); + continue; + } + + if ($requestBody->getContentByType($mimeType)) { + continue; + } + + $requestBody->pushContent( + (new Content()) + ->setMimeType($mimeType) + ->setSchema($this->schema) + ); + } + + $this->operation->setRequestBody($requestBody); + } + + /** + * Adds Schema to the Operations Request Body as application/x-www-form-urlencoded + * + * @param RequestBody $requestBody + * @return RequestBody + */ + private function getRequestBodyWithFormSchema(RequestBody $requestBody) : RequestBody + { + $ignoreSchemas = array_filter($this->annotations, function ($annotation) { + return $annotation instanceof SwagRequestBody && $annotation->ignoreCakeSchema === true; + }); + + if (!empty($ignoreSchemas) || !isset($this->schema)) { + return $requestBody; + } + + $properties = []; + if ($requestBody->getContentByType('application/x-www-form-urlencoded')) { + $properties = $requestBody + ->getContentByType('application/x-www-form-urlencoded') + ->getSchema() + ->getProperties(); + } + + $schema = clone $this->schema; + $schemaProperties = array_filter($schema->getProperties(), function ($property) { + return $property->isReadOnly() === false; + }); + + $properties = array_merge($schemaProperties, $properties); + + $schema->setProperties($properties); + + $requestBody->pushContent( + (new Content()) + ->setMimeType('application/x-www-form-urlencoded') + ->setSchema($schema) + ); + + return $requestBody; + } +} \ No newline at end of file diff --git a/src/Lib/Operation/OperationResponse.php b/src/Lib/Operation/OperationResponse.php new file mode 100644 index 00000000..04d0ed93 --- /dev/null +++ b/src/Lib/Operation/OperationResponse.php @@ -0,0 +1,175 @@ +config = $config; + $this->operation = $operation; + $this->doc = $doc; + $this->annotations = $annotations; + $this->route = $route; + $this->schema = $schema; + } + + /** + * Gets an Operation with Responses + * @return Operation + */ + public function getOperationWithResponses() : Operation + { + $this->assignAnnotations(); + $this->assignDocBlockExceptions(); + $this->assignSchema(); + + return $this->operation; + } + + /** + * Set Responses using SwagResponseSchema + * @return void + */ + private function assignAnnotations() : void + { + $swagResponses = array_filter($this->annotations, function ($annotation) { + return $annotation instanceof SwagResponseSchema; + }); + + foreach ($swagResponses as $annotation) { + $response = (new Response()) + ->setCode(intval($annotation->httpCode)) + ->setDescription($annotation->description); + + if (empty($annotation->schemaFormat) && empty($annotation->mimeType)) { + $this->operation->pushResponse($response); + continue; + } + + $response->pushContent( + (new Content()) + ->setSchema($annotation->refEntity) + ->setFormat($annotation->schemaFormat) + ->setType($annotation->schemaType) + ->setMimeType($annotation->mimeType) + ); + $this->operation->pushResponse($response); + } + } + + /** + * Sets error Responses using throw tags from Dock Block + * @return void + */ + private function assignDocBlockExceptions() : void + { + if (!$this->doc->hasTag('throws')) { + return; + } + + $throws = $this->doc->getTagsByName('throws'); + + $mimeTypes = $this->config->getResponseContentTypes(); + $mimeType = reset($mimeTypes); + + foreach ($throws as $throw) { + $exception = new ExceptionHandler($throw->getType()->__toString()); + + $this->operation->pushResponse( + (new Response()) + ->setCode($exception->getCode()) + ->setDescription($exception->getMessage()) + ->pushContent( + (new Content()) + ->setMimeType($mimeType) + ->setSchema('#/components/schemas/' . $this->config->getExceptionSchema()) + ) + ); + } + } + + /** + * Assigns Cake Models as Swagger Schema if possible + * @return void + */ + private function assignSchema() : void + { + if (!$this->schema) { + return; + } + + if ($this->operation->getResponseByCode(200)) { + return; + } + + if (!in_array(strtolower($this->route->getAction()),['index','add','view','edit'])) { + return; + } + + if (in_array(strtolower($this->route->getAction()),['index'])) { + $response = (new Response())->setCode(200); + + foreach ($this->config->getResponseContentTypes() as $mimeType) { + $response->pushContent( + (new Content()) + ->setSchema($this->schema) + ->setMimeType($mimeType) + ); + } + $this->operation->pushResponse($response); + return; + } + + if (in_array(strtolower($this->route->getAction()),['add','view','edit'])) { + $response = (new Response())->setCode(200); + + foreach ($this->config->getResponseContentTypes() as $mimeType) { + $response->pushContent( + (new Content()) + ->setSchema($this->schema) + ->setMimeType($mimeType) + ); + } + $this->operation->pushResponse($response); + return; + } + } +} \ No newline at end of file diff --git a/src/Lib/Operation/OperationSecurity.php b/src/Lib/Operation/OperationSecurity.php new file mode 100644 index 00000000..4392fd9b --- /dev/null +++ b/src/Lib/Operation/OperationSecurity.php @@ -0,0 +1,36 @@ +pushSecurity( + (new PathSecurity()) + ->setName($annotation->name) + ->setScopes($annotation->scopes) + ); + } + + return $operation; + } +} \ No newline at end of file diff --git a/src/Lib/Path/PathFromRouteFactory.php b/src/Lib/Path/PathFromRouteFactory.php new file mode 100644 index 00000000..39492aaf --- /dev/null +++ b/src/Lib/Path/PathFromRouteFactory.php @@ -0,0 +1,108 @@ +config = $config; + $this->route = $route; + } + + /** + * Creates a Path if possible, otherwise returns null + * + * @return Path|null + */ + public function create() : ?Path + { + if (empty($this->route->getMethods())) { + return null; + } + + $controller = $this->route->getController() . 'Controller'; + $fullyQualifiedNamespace = NamespaceUtility::getControllerFullQualifiedNameSpace($controller, $this->config); + + if (!$this->isVisible($fullyQualifiedNamespace)) { + return null; + } + + return (new Path())->setResource($this->getResourceName()); + } + + /** + * @param string $fullyQualifiedNamespace + * @return bool + */ + private function isVisible(string $fullyQualifiedNamespace) : bool + { + $annotations = AnnotationUtility::getClassAnnotationsFromFqns($fullyQualifiedNamespace); + + $results = array_filter($annotations, function ($annotation) { + return $annotation instanceof SwagPath; + }); + + if (empty($results)) { + return true; + } + + $swagPath = reset($results); + + return $swagPath->isVisible; + } + + /** + * Returns a routes resource (e.g. /api/model/action) + * + * @return string + */ + private function getResourceName() : string + { + $pieces = $this->getRoutablePieces(); + + if ($this->config->getPrefix() == '/') { + return implode('/', $pieces); + } + + return substr( + implode('/', $pieces), + strlen($this->config->getPrefix()) + ); + } + + /** + * Splits the route (URL) into pieces with forward-slash "/" as the separator after removing path variables + * + * @return string[] + */ + private function getRoutablePieces() : array + { + return array_map( + function ($piece) { + if (substr($piece, 0, 1) == ':') { + return '{' . str_replace(':', '', $piece) . '}'; + } + return $piece; + }, + explode('/', $this->route->getTemplate()) + ); + } +} \ No newline at end of file diff --git a/src/Lib/Path/PathFromYmlFactory.php b/src/Lib/Path/PathFromYmlFactory.php new file mode 100644 index 00000000..17645749 --- /dev/null +++ b/src/Lib/Path/PathFromYmlFactory.php @@ -0,0 +1,52 @@ +setResource($resource); + + /* + foreach ($vars as $httpMethod => $var) { + $operation = (new Operation()) + ->setHttpMethod($httpMethod) + ->setSummary(isset($var['summary']) ? $var['summary'] : '') + ->setDescription(isset($var['description']) ? $var['description'] : '') + ->setTags(isset($var['tags']) ? $var['tags'] : []) + ->setOperationId(isset($var['operationId']) ? $var['operationId'] : '') + ->setDeprecated((bool)isset($var['deprecated']) ? $var['deprecated'] : false); + + if (isset($vars['externalDocs'])) { + $path->setExternalDocs( + (new OperationExternalDoc()) + ->setDescription($vars['externalDocs']['description']) + ->setUrl($vars['externalDocs']['url']) + ); + } + + if (isset($vars['security']) && is_array($vars['security'])) { + foreach ($vars['security'] as $key => $scopes) { + $path->pushSecurity((new PathSecurity())->setName($key)->setScopes($scopes)); + } + } + } + } + */ + } +} \ No newline at end of file diff --git a/src/Lib/QueryParameter.php b/src/Lib/QueryParameter.php deleted file mode 100644 index a70ed0cd..00000000 --- a/src/Lib/QueryParameter.php +++ /dev/null @@ -1,43 +0,0 @@ -getMethods() as $method) { - $annotations = $this->reader->getMethodAnnotations($method); - if (empty($annotations)) { - continue; - } - - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagAnnotation\SwagPaginator) { - $return = array_merge( - $return, - (new SwagAnnotation\SwagPaginatorHandler())->getQueryParameters($annotation) - ); - } - if ($annotation instanceof SwagAnnotation\SwagQuery) { - $return = array_merge( - $return, - [ - (new SwagAnnotation\SwagQueryHandler())->getQueryParameter($annotation) - ] - ); - } - } - } - - return $return; - } -} \ No newline at end of file diff --git a/src/Lib/RequestBodyBuilder.php b/src/Lib/RequestBodyBuilder.php deleted file mode 100644 index fdee1e2c..00000000 --- a/src/Lib/RequestBodyBuilder.php +++ /dev/null @@ -1,116 +0,0 @@ -path = $path; - $this->route = $route; - $this->swagger = $swagger; - } - - /** - * @return RequestBody|null - */ - public function build() : ?RequestBody - { - $requestBody = $this->path->getRequestBody(); - if (!$requestBody) { - $requestBody = new RequestBody(); - } - - if (!in_array($this->path->getType(), ['put','patch', 'post'])) { - return null; - } - - $requestBody->setRequired(true); - - return $this->requestBodyWithContent($requestBody); - } - - /** - * @param RequestBody $requestBody - * @return RequestBody - */ - private function requestBodyWithContent(RequestBody $requestBody) : RequestBody - { - $tags = $this->path->getTags(); - $tag = preg_replace('/\s+/', '', reset($tags)); - $tag = Inflector::singularize($tag); - - foreach ($this->swagger->getConfig()->getRequestAccepts() as $mimeType) { - - if ($requestBody->getContentByType($mimeType)) { - continue; - } - - if ($mimeType == 'application/x-www-form-urlencoded') { - $schema = new Schema(); - $schema->setType('object'); - if (!$requestBody->isIgnoreCakeSchema()) { - $schema = $this->withSchemaFromModel($schema, $tag); - } - $schema = $this->withSchemaFromAnnotations($schema); - $requestBody->pushContent((new Content())->setMimeType($mimeType)->setSchema($schema)); - continue; - } - - $schema = '#/components/schemas/' . $tag; - $requestBody->pushContent((new Content())->setMimeType($mimeType)->setSchema($schema)); - } - - return $requestBody; - } - - /** - * @param Schema $schema - * @param string $tag - * @return Schema - */ - private function withSchemaFromModel(Schema $schema, string $tag) : Schema - { - $className = Inflector::classify($tag); - if (!$this->swagger->getSchemaByName($className)) { - return $schema; - } - - foreach ($this->swagger->getSchemaByName($className)->getProperties() as $property) { - - if ($property->isReadOnly()) { - continue; - } - - $schema->pushProperty($property); - } - - return $schema; - } - - /** - * @param Schema $schema - * @return Schema - */ - private function withSchemaFromAnnotations(Schema $schema) : Schema - { - $schemaProperties = (new FormData($this->route, $this->swagger->getConfig()))->getSchemaProperties(); - - if (empty($schemaProperties)) { - return $schema; - } - - foreach ($schemaProperties as $schemaProperty) { - $schema->pushProperty($schemaProperty); - } - - return $schema; - } -} \ No newline at end of file diff --git a/src/Lib/Security.php b/src/Lib/Security.php deleted file mode 100644 index 2151e8d4..00000000 --- a/src/Lib/Security.php +++ /dev/null @@ -1,35 +0,0 @@ -getMethods() as $method) { - $annotations = $this->reader->getMethodAnnotations($method); - if (empty($annotations)) { - continue; - } - - foreach ($annotations as $annotation) { - if ($annotation instanceof SwagAnnotation\SwagSecurity) { - $return = array_merge( - $return, - [(new SwagAnnotation\SwagSecurityHandler())->getPathSecurity($annotation)] - ); - } - } - } - - return $return; - } -} \ No newline at end of file diff --git a/src/Lib/Swagger.php b/src/Lib/Swagger.php index 3c1d3568..73b67500 100644 --- a/src/Lib/Swagger.php +++ b/src/Lib/Swagger.php @@ -5,16 +5,19 @@ use Cake\Utility\Inflector; use SwaggerBake\Lib\Exception\SwaggerBakeRunTimeException; use SwaggerBake\Lib\Factory as Factory; -use SwaggerBake\Lib\Model\ExpressiveRoute; -use SwaggerBake\Lib\OpenApi\Content; -use SwaggerBake\Lib\OpenApi\OperationExternalDoc; +use SwaggerBake\Lib\Decorator\RouteDecorator; +use SwaggerBake\Lib\OpenApi\Operation; use SwaggerBake\Lib\OpenApi\Path; -use SwaggerBake\Lib\OpenApi\PathSecurity; -use SwaggerBake\Lib\OpenApi\Response; use SwaggerBake\Lib\OpenApi\Schema; use SwaggerBake\Lib\OpenApi\SchemaProperty; +use SwaggerBake\Lib\Operation\OperationFromRouteFactory; +use SwaggerBake\Lib\Path\PathFromRouteFactory; use Symfony\Component\Yaml\Yaml; +/** + * Class Swagger + * @package SwaggerBake\Lib + */ class Swagger { /** @var array */ @@ -34,7 +37,9 @@ public function __construct(CakeModel $cakeModel) $this->cakeModel = $cakeModel; $this->cakeRoute = $cakeModel->getCakeRoute(); $this->config = $cakeModel->getConfig(); - $this->buildFromDefaults(); + $this->buildFromYml(); + $this->buildSchemasFromModels(); + $this->buildPathsFromRoutes(); } /** @@ -44,12 +49,9 @@ public function __construct(CakeModel $cakeModel) */ public function getArray(): array { - $this->buildSchemas(); - $this->buildPaths(); - foreach ($this->array['paths'] as $method => $paths) { foreach ($paths as $pathId => $path) { - if (!is_array($path)) { + if ($path instanceof Path) { $this->array['paths'][$method][$pathId] = $path->toArray(); } } @@ -94,7 +96,7 @@ public function toString() * * @param string $output */ - public function writeFile(string $output) : void + public function writeFile(string $output): void { if (!is_writable($output)) { throw new SwaggerBakeRunTimeException("Output file is not writable, given $output"); @@ -141,11 +143,8 @@ public function getSchemaByName(string $name): ?Schema */ public function pushPath(Path $path): Swagger { - $route = $path->getPath(); - $methodType = $path->getType(); - if (!$this->hasPathByRouteAndMethodType($route, $methodType)) { - $this->array['paths'][$route][$methodType] = $path; - } + $resource = $path->getResource(); + $this->array['paths'][$resource] = $path; return $this; } @@ -154,25 +153,15 @@ public function pushPath(Path $path): Swagger * * @return Configuration */ - public function getConfig() : Configuration + public function getConfig(): Configuration { return $this->config; } - /** - * @param string $route - * @param string $methodType - * @return Path|null|mixed - */ - private function hasPathByRouteAndMethodType(string $route, string $methodType): bool - { - return isset($this->array['paths'][$route][$methodType]); - } - /** * Builds schemas from cake models */ - private function buildSchemas(): void + private function buildSchemasFromModels(): void { $schemaFactory = new Factory\SchemaFactory($this->config); $models = $this->cakeModel->getModels(); @@ -192,158 +181,115 @@ private function buildSchemas(): void /** * Builds paths from cake routes */ - private function buildPaths(): void + private function buildPathsFromRoutes(): void { $routes = $this->cakeRoute->getRoutes(); + $operationFactory = new OperationFromRouteFactory($this->config); + + $ignorePaths = array_keys($this->array['paths']); + foreach ($routes as $route) { - $path = (new Factory\PathFactory($route, $this->config))->create(); - if (is_null($path)) { + $resource = $this->convertCakePathToOpenApiResource($route->getTemplate()); + if ($this->hasPathByResource($resource)) { + $path = $this->array['paths'][$resource]; + } else { + $path = (new PathFromRouteFactory($route, $this->config))->create(); + } + + if (!$path instanceof Path) { continue; } - if ($this->hasPathByRouteAndMethodType($path->getPath(), $path->getType())) { + if (in_array($path->getResource(), $ignorePaths)) { continue; } - $path = $this->pathWithResponses($path); - $path = $this->pathWithSecurity($path, $route); - $path = $this->pathWithParameters($path, $route); - $path = $this->pathWithRequestBody($path, $route); + foreach ($route->getMethods() as $httpMethod) { - $this->pushPath($path); + if (strtolower($httpMethod) == 'put') { + continue; + } + + $this->addArrayOfObjectsSchema($route); + + $schema = $this->getSchemaFromRoute($route); + + $operation = $operationFactory->create($route, $httpMethod, $schema); + + if (!$operation instanceof Operation) { + continue; + } + + $path->pushOperation($operation); + } + + if (!empty($path->getOperations())) { + $this->pushPath($path); + } } } /** - * Sets security on a path - * - * @param Path $path - * @param ExpressiveRoute $route - * @return Path + * @param RouteDecorator $route + * @return Schema|null */ - private function pathWithSecurity(Path $path, ExpressiveRoute $route) : Path + private function getSchemaFromRoute(RouteDecorator $route) : ?Schema { - $path->setSecurity((new Security($route, $this->config))->getPathSecurity()); - return $path; - } + $controller = $route->getController(); + $name = preg_replace('/\s+/', '', $controller); - /** - * Sets header parameters on a path - * - * @param Path $path - * @param ExpressiveRoute $route - * @return Path - */ - private function pathWithParameters(Path $path, ExpressiveRoute $route) : Path - { - $headers = (new HeaderParameter($route, $this->config))->getHeaderParameters(); - foreach ($headers as $parameter) { - $path->pushParameter($parameter); + if (in_array(strtolower($route->getAction()),['index']) && $this->getSchemaByName($name)) { + return $this->getSchemaByName($name); } - $queries = (new QueryParameter($route, $this->config))->getQueryParameters(); - foreach ($queries as $parameter) { - $path->pushParameter($parameter); + if (in_array(strtolower($route->getAction()),['add','view','edit'])) { + return $this->getSchemaByName(Inflector::singularize($name)); } - return $path; + + return null; } /** - * Sets responses on a path + * Adds array of objects to #/components/schemas * - * @param Path $path - * @return Path + * @param RouteDecorator $route */ - private function pathWithResponses(Path $path) : Path + private function addArrayOfObjectsSchema(RouteDecorator $route) : void { - foreach ($path->getTags() as $tag) { - $className = Inflector::classify($tag); - - if ($path->hasSuccessResponseCode() || !$this->getSchemaByName($className)) { - continue; - } - - if ($path->getType() == 'get' && strstr($path->getOperationId(),':index')) { - $tags = $path->getTags(); - $tag = preg_replace('/\s+/', '', reset($tags)); - $schema = (new Schema()) - ->setName($tag) - ->setType('array') - ->setItems(['$ref' => '#/components/schemas/' . $className]) - ; - $this->pushSchema($schema); - - $response = (new Response())->setCode(200); - $response = $this->responseWithContent($response, '#/components/schemas/' . $tag); - $path->pushResponse($response); - continue; - } - - $response = (new Response())->setCode(200); - $response = $this->responseWithContent($response, '#/components/schemas/' . $className); - $path->pushResponse($response); - } - - if (!$path->hasSuccessResponseCode()) { - $path->pushResponse((new Response())->setCode(200)); + if (!in_array('GET', $route->getMethods())) { + return; } - $exceptionSchema = $this->getSchemaByName($this->getConfig()->getExceptionSchema()); - if (!$exceptionSchema) { - return $path; + if ($route->getAction() !== 'index') { + return; } - foreach ($path->getResponses() as $response) { - if ($response->getCode() < 400) { - continue; - } - $path->pushResponse( - $this->responseWithContent($response, '#/components/schemas/' . $exceptionSchema->getName()) - ); + if ($this->getSchemaByName($route->getController())) { + return; } - return $path; - } - - /** - * @param Response $response - * @param string $schema - * @return Response - */ - private function responseWithContent(Response $response, string $schema) : Response - { - foreach ($this->config->getResponseContentTypes() as $mimeType) { - $response->pushContent((new Content())->setMimeType($mimeType)->setSchema($schema)); + if (!$this->getSchemaByName(Inflector::singularize($route->getController()))) { + return; } - return $response; - } - /** - * Sets a request body on a path - * - * @param Path $path - * @param ExpressiveRoute $route - * @return Path - */ - private function pathWithRequestBody(Path $path, ExpressiveRoute $route) : Path - { - $requestBody = (new RequestBodyBuilder($path, $this, $route))->build(); - if ($requestBody) { - $path->setRequestBody($requestBody); - } - return $path; + $this->pushSchema( + (new Schema()) + ->setName($route->getController()) + ->setType('array') + ->setItems(['$ref' => '#/components/schemas/' . Inflector::singularize($route->getController())]) + ); } /** * Constructs the primary array used in this class from pre-defined swagger.yml */ - private function buildFromDefaults() : void + private function buildFromYml() : void { $array = Yaml::parseFile($this->config->getYml()); - $array = $this->buildFromDefaultPaths($array); - $array = $this->buildFromDefaultSchemas($array); + $array = $this->buildPathsFromYml($array); + $array = $this->buildSchemaFromYml($array); $this->array = $array; } @@ -355,42 +301,13 @@ private function buildFromDefaults() : void * @param $array * @return array */ - private function buildFromDefaultPaths($array) : array + private function buildPathsFromYml($array) : array { if (!isset($array['paths'])) { $array['paths'] = []; } return $array; - - /* - foreach ($array['paths'] as $path => $operations) { - - foreach ($operations as $httpMethod => $vars) { - $path = (new Path()) - ->setType($httpMethod) - ->setSummary(isset($var['summary']) ? $var['summary'] : '') - ->setDescription(isset($var['description']) ? $var['description'] : '') - ->setTags(isset($var['tags']) ? $var['tags'] : []) - ->setOperationId(isset($var['operationId']) ? $var['operationId'] : '') - ->setDeprecated((bool) isset($var['deprecated']) ? $var['deprecated'] : false) - - if (isset($vars['externalDocs'])) { - $path->setExternalDocs( - (new OperationExternalDoc()) - ->setDescription($vars['externalDocs']['description']) - ->setUrl($vars['externalDocs']['url']) - ); - } - - if (isset($vars['security']) && is_array($vars['security'])) { - foreach ($vars['security'] as $key => $scopes) { - $path->pushSecurity((new PathSecurity())->setName($key)->setScopes($scopes)); - } - } - } - } - */ } /** @@ -399,7 +316,7 @@ private function buildFromDefaultPaths($array) : array * @param $array * @return array */ - private function buildFromDefaultSchemas($array) : array + private function buildSchemaFromYml($array) : array { if (!isset($array['components']['schemas'])) { $array['components']['schemas'] = []; @@ -431,6 +348,67 @@ private function buildFromDefaultSchemas($array) : array return $array; } + /** + * Converts Cake path parameters to OpenApi Spec + * + * @example /actor/:id to /actor/{id} + * @param string $resource + * @return string + */ + private function convertCakePathToOpenApiResource(string $resource) : string + { + $pieces = array_map( + function ($piece) { + if (substr($piece, 0, 1) == ':') { + return '{' . str_replace(':', '', $piece) . '}'; + } + return $piece; + }, + explode('/', $resource) + ); + + if ($this->config->getPrefix() == '/') { + return implode('/', $pieces); + } + + return substr( + implode('/', $pieces), + strlen($this->config->getPrefix()) + ); + } + + /** + * @return Operation[] + */ + public function getOperationsWithNoHttp20x() : array + { + $operations = []; + + foreach ($this->array['paths'] as $path) { + + if (!$path instanceof Path) { + continue; + } + + $operations = array_merge( + $operations, + array_filter($path->getOperations(), function ($operation) { + return !$operation->hasSuccessResponseCode(); + }) + ); + } + return $operations; + } + + /** + * @param string $resource + * @return Path|null|mixed + */ + private function hasPathByResource(string $resource): bool + { + return isset($this->array['paths'][$resource]); + } + public function __toString(): string { return $this->toString(); diff --git a/src/Lib/Utility/AnnotationUtility.php b/src/Lib/Utility/AnnotationUtility.php index bf4c1245..03e2973b 100644 --- a/src/Lib/Utility/AnnotationUtility.php +++ b/src/Lib/Utility/AnnotationUtility.php @@ -6,17 +6,21 @@ use Exception; use ReflectionClass; +/** + * Class AnnotationUtility + * @package SwaggerBake\Lib\Utility + */ class AnnotationUtility { /** - * Gets class annotations from full namespace argument + * Gets class annotations from namespace argument * * @uses AnnotationReader * @uses ReflectionClass * @param string $namespace * @return array */ - public static function getClassAnnotations(string $namespace) : array + public static function getClassAnnotationsFromFqns(string $namespace) : array { try { $instance = new $namespace; @@ -36,6 +40,33 @@ public static function getClassAnnotations(string $namespace) : array return $annotations; } + /** + * Gets class annotations from instance + * + * @uses AnnotationReader + * @uses ReflectionClass + * @param object $instance + * @return array + */ + public static function getClassAnnotationsFromInstance(object $instance) : array + { + try { + $reflectionClass = new ReflectionClass(get_class($instance)); + } catch (Exception $e) { + return []; + } + + $reader = new AnnotationReader(); + + $annotations = $reader->getClassAnnotations($reflectionClass); + + if (!is_array($annotations)) { + return []; + } + + return $annotations; + } + /** * Returns an array of Lib/Annotation objects that can be applied to methods * diff --git a/src/Lib/Utility/DataTypeConversion.php b/src/Lib/Utility/DataTypeConversion.php index e5df5398..2c75d1ec 100644 --- a/src/Lib/Utility/DataTypeConversion.php +++ b/src/Lib/Utility/DataTypeConversion.php @@ -1,9 +1,11 @@ getNamespaces(); + + if (!isset($namespaces['controllers']) || !is_array($namespaces['controllers'])) { + throw new SwaggerBakeRunTimeException( + 'Invalid configuration, missing SwaggerBake.namespaces.controllers' + ); + } + + foreach ($namespaces['controllers'] as $namespace) { + $entity = $namespace . 'Controller\\' . $className; + if (class_exists($entity, true)) { + return $entity; + } + } + + return null; + } + + /** + * Gets a FQNS of an Entity + * + * @param string $className + * @param Configuration $config + * @return string|null + */ + public static function getEntityFullyQualifiedNameSpace(string $className, Configuration $config) : ?string + { + $namespaces = $config->getNamespaces(); + + if (!isset($namespaces['entities']) || !is_array($namespaces['entities'])) { + throw new SwaggerBakeRunTimeException( + 'Invalid configuration, missing SwaggerBake.namespaces.entities' + ); + } + + foreach ($namespaces['entities'] as $namespace) { + $entity = $namespace . 'Model\Entity\\' . $className; + if (class_exists($entity, true)) { + return $entity; + } + } + + return null; + } + + /** + * Gets a FQNS of a Table + * + * @param string $className + * @param Configuration $config + * @return string|null + */ + public static function getTableFullyQualifiedNameSpace(string $className, Configuration $config) : ?string + { + $namespaces = $config->getNamespaces(); + + if (!isset($namespaces['tables']) || !is_array($namespaces['tables'])) { + throw new SwaggerBakeRunTimeException( + 'Invalid configuration, missing SwaggerBake.namespaces.tables' + ); + } + + foreach ($namespaces['tables'] as $namespace) { + $table = $namespace . 'Model\Table\\' . $className; + if (class_exists($table, true)) { + return $table; + } + } + + return null; + } +} \ No newline at end of file diff --git a/src/Lib/Utility/OpenApiDataType.php b/src/Lib/Utility/OpenApiDataType.php index d3fa7a85..356961cc 100644 --- a/src/Lib/Utility/OpenApiDataType.php +++ b/src/Lib/Utility/OpenApiDataType.php @@ -2,6 +2,10 @@ namespace SwaggerBake\Lib\Utility; +/** + * Class OpenApiDataType + * @package SwaggerBake\Lib\Utility + */ class OpenApiDataType { public const TYPES = ['array', 'boolean', 'integer', 'number', 'object', 'string']; diff --git a/src/Lib/Utility/ValidateConfiguration.php b/src/Lib/Utility/ValidateConfiguration.php index 421c71d8..de4bf7d3 100644 --- a/src/Lib/Utility/ValidateConfiguration.php +++ b/src/Lib/Utility/ValidateConfiguration.php @@ -6,6 +6,10 @@ use SwaggerBake\Lib\Configuration; use SwaggerBake\Lib\Exception\SwaggerBakeRunTimeException; +/** + * Class ValidateConfiguration + * @package SwaggerBake\Lib\Utility\ + */ class ValidateConfiguration { public static function validate() : void diff --git a/src/Plugin.php b/src/Plugin.php index f9a30bc2..49a7c2b4 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -8,9 +8,7 @@ use Cake\Core\Configure; use Cake\Core\PluginApplicationInterface; use SwaggerBake\Lib\AnnotationLoader; -use SwaggerBake\Command\BakeCommand; -use SwaggerBake\Command\ModelCommand; -use SwaggerBake\Command\RouteCommand; +use SwaggerBake\Command as Commands; /** * Class Plugin @@ -21,15 +19,20 @@ class Plugin extends BasePlugin public function bootstrap(PluginApplicationInterface $app) : void { parent::bootstrap($app); + if (!file_exists(CONFIG . 'swagger_bake.php')) { + triggerWarning('Missing configuration file for config/swagger_bake.php'); + return; + } Configure::load('swagger_bake', 'default'); AnnotationLoader::load(); } public function console(CommandCollection $commands): CommandCollection { - $commands->add('swagger routes', RouteCommand::class); - $commands->add('swagger bake', BakeCommand::class); - $commands->add('swagger models', ModelCommand::class); + $commands->add('swagger routes', Commands\RouteCommand::class); + $commands->add('swagger bake', Commands\BakeCommand::class); + $commands->add('swagger models', Commands\ModelCommand::class); + $commands->add('swagger install', Commands\InstallCommand::class); return $commands; } diff --git a/templates/element/default.php b/templates/element/default.php new file mode 100644 index 00000000..2978da41 --- /dev/null +++ b/templates/element/default.php @@ -0,0 +1,15 @@ + +
\ No newline at end of file diff --git a/templates/element/error.php b/templates/element/error.php new file mode 100644 index 00000000..bf4eaf23 --- /dev/null +++ b/templates/element/error.php @@ -0,0 +1,11 @@ + +
\ No newline at end of file diff --git a/templates/layout/default.php b/templates/layout/default.php index 12dc0a9f..aff25051 100644 --- a/templates/layout/default.php +++ b/templates/layout/default.php @@ -31,11 +31,25 @@ margin:0; background: #fafafa; } + /* Flash messages */ + .message,.alert { + padding: 1rem; + border-width: 1px; + border-style: solid; + border-radius: 4px; + margin-bottom: 2rem; + } + .message.error, .alert.alert-danger { + background: #fcebea; + color: #cc1f1a; + border-color: #ef5753; + } Flash->render(); echo $this->fetch('content'); ?> diff --git a/templates/layout/redoc.php b/templates/layout/redoc.php index 008dca96..76ea2337 100644 --- a/templates/layout/redoc.php +++ b/templates/layout/redoc.php @@ -19,10 +19,29 @@ margin: 0; padding: 0; } + /* Flash messages */ + .alert { + padding: 1rem; + background: #eff8ff; + color: #2779bd; + border-color: #6cb2eb; + border-width: 1px; + border-style: solid; + border-radius: 4px; + margin-bottom: 2rem; + } + .alert-error { + background: #fcebea; + color: #cc1f1a; + border-color: #ef5753; + } Flash)) { + echo $this->Flash->render(); die; +} echo $this->fetch('content'); ?> diff --git a/tests/TestCase/Lib/Operation/ExceptionHandlerTest.php b/tests/TestCase/Lib/Operation/ExceptionHandlerTest.php new file mode 100644 index 00000000..e5fc6d78 --- /dev/null +++ b/tests/TestCase/Lib/Operation/ExceptionHandlerTest.php @@ -0,0 +1,19 @@ +assertEquals(400, (new ExceptionHandler('BadRequestException'))->getCode()); + $this->assertEquals(401, (new ExceptionHandler('UnauthorizedException'))->getCode()); + $this->assertEquals(403, (new ExceptionHandler('ForbiddenException'))->getCode()); + $this->assertEquals(404, (new ExceptionHandler('RecordNotFoundException'))->getCode()); + $this->assertEquals(405, (new ExceptionHandler('MethodNotAllowedException'))->getCode()); + $this->assertEquals(500, (new ExceptionHandler('Exception'))->getCode()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Operation/OperationDocBlockTest.php b/tests/TestCase/Lib/Operation/OperationDocBlockTest.php new file mode 100644 index 00000000..23c34085 --- /dev/null +++ b/tests/TestCase/Lib/Operation/OperationDocBlockTest.php @@ -0,0 +1,36 @@ +getOperationWithDocBlock( + new Operation(), + DocBlockFactory::createInstance()->create($block) + ); + + $doc = $operation->getExternalDocs(); + + $this->assertEquals('CakePHP', $doc->getDescription()); + $this->assertEquals('http://www.cakephp.org', $doc->getUrl()); + $this->assertTrue($operation->isDeprecated()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Operation/OperationFromRouteFactoryTest.php b/tests/TestCase/Lib/Operation/OperationFromRouteFactoryTest.php new file mode 100644 index 00000000..72c01bcf --- /dev/null +++ b/tests/TestCase/Lib/Operation/OperationFromRouteFactoryTest.php @@ -0,0 +1,64 @@ +setExtensions(['json']); + $builder->resources('Employees', [ + 'only' => 'index' + ]); + }); + $this->router = $router; + + $this->config = [ + 'prefix' => '/api', + 'yml' => '/config/swagger-bare-bones.yml', + 'json' => '/webroot/swagger.json', + 'webPath' => '/swagger.json', + 'hotReload' => false, + 'exceptionSchema' => 'Exception', + 'requestAccepts' => ['application/x-www-form-urlencoded'], + 'responseContentTypes' => ['application/json'], + 'namespaces' => [ + 'controllers' => ['\SwaggerBakeTest\App\\'], + 'entities' => ['\SwaggerBakeTest\App\\'], + 'tables' => ['\SwaggerBakeTest\App\\'], + ] + ]; + } + + public function testCreate() + { + $config = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + $cakeRoute = new CakeRoute($this->router, $config); + + $routes = $cakeRoute->getRoutes(); + $route = reset($routes); + + $operation = (new OperationFromRouteFactory($config))->create($route, 'GET', null); + $this->assertInstanceOf(Operation::class, $operation); + $this->assertEquals('GET', $operation->getHttpMethod()); + $this->assertEquals('employees:index', $operation->getOperationId()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Operation/OperationFromYmlFactoryTest.php b/tests/TestCase/Lib/Operation/OperationFromYmlFactoryTest.php new file mode 100644 index 00000000..694de5fb --- /dev/null +++ b/tests/TestCase/Lib/Operation/OperationFromYmlFactoryTest.php @@ -0,0 +1,24 @@ +create('GET', [ + 'tags' => ['hello'], + 'operationId' => 'operation:id', + 'deprecated' => false + ]); + $this->assertInstanceOf(Operation::class, $operation); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Operation/OperationHeaderTest.php b/tests/TestCase/Lib/Operation/OperationHeaderTest.php new file mode 100644 index 00000000..2c7712d6 --- /dev/null +++ b/tests/TestCase/Lib/Operation/OperationHeaderTest.php @@ -0,0 +1,25 @@ +getOperationWithHeaders( + new Operation(), + [new SwagHeader(['name' => 'X-HEADER','type' => 'string', 'description' => '', 'required' => false])] + ); + + $parameters = $operation->getParameters(); + $param = reset($parameters); + $this->assertEquals('X-HEADER', $param->getName()); + $this->assertEquals('header', $param->getIn()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Operation/OperationPathTest.php b/tests/TestCase/Lib/Operation/OperationPathTest.php new file mode 100644 index 00000000..2c89aaee --- /dev/null +++ b/tests/TestCase/Lib/Operation/OperationPathTest.php @@ -0,0 +1,35 @@ + ['GET'], + 'plugin' => '', + 'controller' => 'Employees', + 'action' => 'view' + ]) + ); + + $operation = (new OperationPath()) + ->getOperationWithPathParameters( + new Operation(), + $routeDecorator + ); + + $parameters = $operation->getParameters(); + $param = reset($parameters); + $this->assertEquals('id', $param->getName()); + $this->assertEquals('path', $param->getIn()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Operation/OperationQueryParameterTest.php b/tests/TestCase/Lib/Operation/OperationQueryParameterTest.php new file mode 100644 index 00000000..8c8cc44c --- /dev/null +++ b/tests/TestCase/Lib/Operation/OperationQueryParameterTest.php @@ -0,0 +1,41 @@ +getOperationWithQueryParameters( + (new Operation())->setHttpMethod('GET'), + [ + new SwagPaginator(), + new SwagQuery(['name' => 'test', 'type' => 'string', 'description' => '', 'required' => false]), + new SwagDto(['class' => '\SwaggerBakeTest\App\Dto\EmployeeData']) + ] + ); + + $parameters = $operation->getParameters(); + $this->assertCount(7, $parameters); + + $param = reset($parameters); + $this->assertEquals('page', $param->getName()); + $this->assertEquals('query', $param->getIn()); + + $param = $parameters[4]; + $this->assertEquals('test', $param->getName()); + $this->assertEquals('query', $param->getIn()); + + $param = end($parameters); + $this->assertEquals('firstName', $param->getName()); + $this->assertEquals('query', $param->getIn()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Operation/OperationRequestBodyTest.php b/tests/TestCase/Lib/Operation/OperationRequestBodyTest.php new file mode 100644 index 00000000..4b6be7a2 --- /dev/null +++ b/tests/TestCase/Lib/Operation/OperationRequestBodyTest.php @@ -0,0 +1,163 @@ +setExtensions(['json']); + $builder->resources('Employees', [ + 'only' => ['create'] + ]); + }); + $this->router = $router; + + $this->config = [ + 'prefix' => '/api', + 'yml' => '/config/swagger-bare-bones.yml', + 'json' => '/webroot/swagger.json', + 'webPath' => '/swagger.json', + 'hotReload' => false, + 'exceptionSchema' => 'Exception', + 'requestAccepts' => ['application/x-www-form-urlencoded'], + 'responseContentTypes' => ['application/json'], + 'namespaces' => [ + 'controllers' => ['\SwaggerBakeTest\App\\'], + 'entities' => ['\SwaggerBakeTest\App\\'], + 'tables' => ['\SwaggerBakeTest\App\\'], + ] + ]; + } + + public function testSwagFormGetOperationWithRequestBody() + { + $config = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + $cakeRoute = new CakeRoute($this->router, $config); + + $routes = $cakeRoute->getRoutes(); + $route = $routes['employees:add']; + + $operationRequestBody = new OperationRequestBody( + $config, + (new Operation())->setHttpMethod('POST'), + DocBlockFactory::createInstance()->create('/** @throws Exception */'), + [ + new SwagForm(['name' => 'test', 'type' => 'string', 'description' => '', 'required' => false]) + ], + $route, + null + ); + + $operation = $operationRequestBody->getOperationWithRequestBody(); + + $requestBody = $operation->getRequestBody(); + $content = $requestBody->getContentByType('application/x-www-form-urlencoded'); + + $schema = $content->getSchema(); + $this->assertEquals('object', $schema->getType()); + + $properties = $schema->getProperties(); + $this->assertArrayHasKey('test', $properties);; + } + + public function testSwagDtoGetOperationWithRequestBody() + { + $config = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + $cakeRoute = new CakeRoute($this->router, $config); + + $routes = $cakeRoute->getRoutes(); + $route = $routes['employees:add']; + + $operationRequestBody = new OperationRequestBody( + $config, + (new Operation())->setHttpMethod('POST'), + DocBlockFactory::createInstance()->create('/** @throws Exception */'), + [ + new SwagDto(['class' => '\SwaggerBakeTest\App\Dto\EmployeeData']) + ], + $route, + null + ); + + $operation = $operationRequestBody->getOperationWithRequestBody(); + + $requestBody = $operation->getRequestBody(); + $content = $requestBody->getContentByType('application/x-www-form-urlencoded'); + + $schema = $content->getSchema(); + $this->assertEquals('object', $schema->getType()); + + $properties = $schema->getProperties(); + $this->assertArrayHasKey('lastName', $properties); + $this->assertArrayHasKey('firstName', $properties); + } + + public function testSchemaGetOperationWithRequestBodyForm() + { + $config = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + $cakeRoute = new CakeRoute($this->router, $config); + + $routes = $cakeRoute->getRoutes(); + $route = $routes['employees:add']; + + $schema = (new Schema()) + ->setType('object') + ->setName('Employee') + ->setProperties([ + (new SchemaProperty())->setName('id')->setType('integer')->setReadOnly(true), + (new SchemaProperty())->setName('firstName')->setType('string')->setRequired(true), + (new SchemaProperty())->setName('otherField')->setType('string') + ]) + ; + + $operationRequestBody = new OperationRequestBody( + $config, + (new Operation())->setHttpMethod('POST'), + DocBlockFactory::createInstance()->create('/** */'), + [], + $route, + $schema + ); + + $operation = $operationRequestBody->getOperationWithRequestBody(); + + $content = $operation + ->getRequestBody() + ->getContentByType('application/x-www-form-urlencoded') + ; + + $schema = $content->getSchema(); + $this->assertEquals('object', $schema->getType()); + + $properties = $schema->getProperties(); + $this->assertArrayNotHasKey('id', $properties); + $this->assertArrayNotHasKey('modified', $properties); + $this->assertTrue($properties['firstName']->isRequired()); + $this->assertEquals('firstName', $properties['firstName']->getName()); + $this->assertEquals('otherField', $properties['otherField']->getName()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Operation/OperationResponseTest.php b/tests/TestCase/Lib/Operation/OperationResponseTest.php new file mode 100644 index 00000000..eb33e175 --- /dev/null +++ b/tests/TestCase/Lib/Operation/OperationResponseTest.php @@ -0,0 +1,135 @@ +setExtensions(['json']); + $builder->resources('Employees', [ + 'only' => ['index','create','delete'] + ]); + }); + $this->router = $router; + + $this->config = [ + 'prefix' => '/api', + 'yml' => '/config/swagger-bare-bones.yml', + 'json' => '/webroot/swagger.json', + 'webPath' => '/swagger.json', + 'hotReload' => false, + 'exceptionSchema' => 'Exception', + 'requestAccepts' => ['application/x-www-form-urlencoded'], + 'responseContentTypes' => ['application/json'], + 'namespaces' => [ + 'controllers' => ['\SwaggerBakeTest\App\\'], + 'entities' => ['\SwaggerBakeTest\App\\'], + 'tables' => ['\SwaggerBakeTest\App\\'], + ] + ]; + } + + public function testGetOperationWithAnnotatedResponse() + { + $config = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + $cakeRoute = new CakeRoute($this->router, $config); + + $routes = $cakeRoute->getRoutes(); + $route = $routes['employees:index']; + + $operationResponse = new OperationResponse( + $config, + new Operation(), + DocBlockFactory::createInstance()->create('/** @throws Exception */'), + [ + new SwagResponseSchema([ + 'refEntity' => '', + 'httpCode' => 200, + 'description' => '', + 'mimeType' => '', + 'schemaType' => '', + 'schemaFormat' => '' + ]), + ], + $route, + null + ); + + $operation = $operationResponse->getOperationWithResponses(); + + $this->assertInstanceOf(Response::class, $operation->getResponseByCode(200)); + $this->assertInstanceOf(Response::class, $operation->getResponseByCode(500)); + } + + public function testGetOperationWithSchemaResponse() + { + $config = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + $cakeRoute = new CakeRoute($this->router, $config); + + $routes = $cakeRoute->getRoutes(); + $route = $routes['employees:add']; + + $schema = (new Schema()) + ->setName('Employee') + ->setType('object') + ; + + $operationResponse = new OperationResponse( + $config, + new Operation(), + DocBlockFactory::createInstance()->create('/** */'), + [], + $route, + $schema + ); + + $operation = $operationResponse->getOperationWithResponses(); + + $this->assertInstanceOf(Response::class, $operation->getResponseByCode(200)); + } + + public function testGetOperationWithNoResponse() + { + $config = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + $cakeRoute = new CakeRoute($this->router, $config); + + $routes = $cakeRoute->getRoutes(); + $route = $routes['employees:delete']; + + $operationResponse = new OperationResponse( + $config, + new Operation(), + DocBlockFactory::createInstance()->create('/** */'), + [], + $route, + null + ); + + $operation = $operationResponse->getOperationWithResponses(); + + $this->assertEmpty($operation->getResponses()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Operation/OperationSecurityTest.php b/tests/TestCase/Lib/Operation/OperationSecurityTest.php new file mode 100644 index 00000000..84b12ecd --- /dev/null +++ b/tests/TestCase/Lib/Operation/OperationSecurityTest.php @@ -0,0 +1,27 @@ +getOperationWithSecurity( + new Operation(), + [new SwagSecurity(['name' => 'BearerAuth' , 'scopes' => ['read','write']])] + ); + + $securities = $operation->getSecurity(); + $security = reset($securities); + $this->assertEquals('BearerAuth', $security->getName()); + $this->assertCount(2, $security->getScopes()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Path/PathFromRouteFactoryTest.php b/tests/TestCase/Lib/Path/PathFromRouteFactoryTest.php new file mode 100644 index 00000000..e8a9a48e --- /dev/null +++ b/tests/TestCase/Lib/Path/PathFromRouteFactoryTest.php @@ -0,0 +1,60 @@ +setExtensions(['json']); + $builder->resources('Employees', [ + 'only' => 'index' + ]); + }); + $this->router = $router; + + $this->config = [ + 'prefix' => '/api', + 'yml' => '/config/swagger-bare-bones.yml', + 'json' => '/webroot/swagger.json', + 'webPath' => '/swagger.json', + 'hotReload' => false, + 'exceptionSchema' => 'Exception', + 'requestAccepts' => ['application/x-www-form-urlencoded'], + 'responseContentTypes' => ['application/json'], + 'namespaces' => [ + 'controllers' => ['\SwaggerBakeTest\App\\'], + 'entities' => ['\SwaggerBakeTest\App\\'], + 'tables' => ['\SwaggerBakeTest\App\\'], + ] + ]; + } + + public function testCreatePath() + { + $config = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + $cakeRoute = new CakeRoute($this->router, $config); + + $routes = $cakeRoute->getRoutes(); + $route = reset($routes); + + $path = (new PathFromRouteFactory($route, $config))->create(); + $this->assertInstanceOf(Path::class, $path); + $this->assertEquals('/employees', $path->getResource()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/Path/PathFromYmlFactoryTest.php b/tests/TestCase/Lib/Path/PathFromYmlFactoryTest.php new file mode 100644 index 00000000..26e1e05a --- /dev/null +++ b/tests/TestCase/Lib/Path/PathFromYmlFactoryTest.php @@ -0,0 +1,24 @@ +create('/pets', [ + 'summary' => 'pet summary', + 'description' => 'lorem ipsum description' + ]); + $this->assertInstanceOf(Path::class, $path); + $this->assertEquals('/pets', $path->getResource()); + } +} \ No newline at end of file diff --git a/tests/TestCase/Lib/SwaggerOperationTest.php b/tests/TestCase/Lib/SwaggerOperationTest.php index 705c3d5f..585d1eff 100644 --- a/tests/TestCase/Lib/SwaggerOperationTest.php +++ b/tests/TestCase/Lib/SwaggerOperationTest.php @@ -74,6 +74,103 @@ public function setUp(): void AnnotationLoader::load(); } + public function testCrudOperationsExist() + { + $configuration = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + + $cakeRoute = new CakeRoute($this->router, $configuration); + $swagger = new Swagger(new CakeModel($cakeRoute, $configuration)); + + $arr = json_decode($swagger->toString(), true); + + $this->assertArrayHasKey('get', $arr['paths']['/employees']); + $this->assertArrayHasKey('post', $arr['paths']['/employees']); + $this->assertArrayHasKey('get', $arr['paths']['/employees/{id}']); + $this->assertArrayHasKey('patch', $arr['paths']['/employees/{id}']); + $this->assertArrayHasKey('delete', $arr['paths']['/employees/{id}']); + + } + + public function testDefaultResponseSchemaOnIndexMethod() + { + $configuration = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + + $cakeRoute = new CakeRoute($this->router, $configuration); + $swagger = new Swagger(new CakeModel($cakeRoute, $configuration)); + + $arr = json_decode($swagger->toString(), true); + + $employee = $arr['paths']['/employees']['get']; + $schema = $employee['responses'][200]['content']['application/json']['schema']; + + $this->assertEquals('array', $schema['type']); + $this->assertEquals('#/components/schemas/Employee', $schema['items']['$ref']); + } + + public function testDefaultRequestSchemaOnAddMethod() + { + $configuration = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + + $cakeRoute = new CakeRoute($this->router, $configuration); + $swagger = new Swagger(new CakeModel($cakeRoute, $configuration)); + + $arr = json_decode($swagger->toString(), true); + + $employee = $arr['paths']['/employees']['post']; + $schema = $employee['requestBody']['content']['application/x-www-form-urlencoded']['schema']; + + $this->assertEquals('object', $schema['type']); + $this->assertCount(4, $schema['properties']); + } + + public function testDefaultResponseSchemaOnAddMethod() + { + $configuration = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + + $cakeRoute = new CakeRoute($this->router, $configuration); + $swagger = new Swagger(new CakeModel($cakeRoute, $configuration)); + + $arr = json_decode($swagger->toString(), true); + + $employee = $arr['paths']['/employees']['post']; + $schema = $employee['responses'][200]['content']['application/json']['schema']; + + $this->assertEquals('object', $schema['type']); + $this->assertCount(6, $schema['properties']); + } + + public function testDefaultRequestSchemaOnEditMethod() + { + $configuration = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + + $cakeRoute = new CakeRoute($this->router, $configuration); + $swagger = new Swagger(new CakeModel($cakeRoute, $configuration)); + + $arr = json_decode($swagger->toString(), true); + + $employee = $arr['paths']['/employees/{id}']['patch']; + $schema = $employee['requestBody']['content']['application/x-www-form-urlencoded']['schema']; + + $this->assertEquals('object', $schema['type']); + $this->assertCount(4, $schema['properties']); + } + + public function testDefaultResponseSchemaOnEditMethod() + { + $configuration = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); + + $cakeRoute = new CakeRoute($this->router, $configuration); + $swagger = new Swagger(new CakeModel($cakeRoute, $configuration)); + + $arr = json_decode($swagger->toString(), true); + + $employee = $arr['paths']['/employees/{id}']['patch']; + $schema = $employee['responses'][200]['content']['application/json']['schema']; + + $this->assertEquals('object', $schema['type']); + $this->assertCount(6, $schema['properties']); + } + public function testHiddenOperation() { $configuration = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); diff --git a/tests/TestCase/Lib/SwaggerSchemaTest.php b/tests/TestCase/Lib/SwaggerSchemaTest.php index 20104b37..3462d9e9 100644 --- a/tests/TestCase/Lib/SwaggerSchemaTest.php +++ b/tests/TestCase/Lib/SwaggerSchemaTest.php @@ -26,6 +26,7 @@ public function setUp(): void $router::scope('/api', function (RouteBuilder $builder) { $builder->setExtensions(['json']); $builder->resources('Employees'); + $builder->resources('EmployeeSalaries'); }); $this->router = $router; @@ -62,5 +63,22 @@ public function testEmployeeTableProperties() $this->assertCount(4, $employee['required']); $this->assertEquals('birth_date', $employee['required'][0]); $this->assertArrayHasKey('birth_date', $employee['properties']); + + $this->assertTrue($employee['properties']['id']['readOnly']); + $this->assertEquals('integer', $employee['properties']['id']['type']); + } + + public function testYmlSchemaTakesPrecedence() + { + $cakeRoute = new CakeRoute($this->router, $this->config); + + $swagger = new Swagger(new CakeModel($cakeRoute, $this->config)); + + $arr = json_decode($swagger->toString(), true); + + $this->assertArrayHasKey('EmployeeSalaries', $arr['components']['schemas']); + $employee = $arr['components']['schemas']['EmployeeSalaries']; + + $this->assertEquals('Test YML schema cannot be overwritten', $employee['description']); } } \ No newline at end of file diff --git a/tests/test_app/config/swagger-with-existing.yml b/tests/test_app/config/swagger-with-existing.yml index 0b234a57..c0170d51 100644 --- a/tests/test_app/config/swagger-with-existing.yml +++ b/tests/test_app/config/swagger-with-existing.yml @@ -146,6 +146,11 @@ components: type: array items: $ref: "#/components/schemas/Pet" + EmployeeSalaries: + description: Test YML schema cannot be overwritten + type: array + items: + $ref: "#/components/schemas/EmployeeSalary" Error: type: object required: