diff --git a/README.md b/README.md index 446c9ff8..dc04ca36 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,9 @@ build the following from your existing routes and models without additional effo - Sub resources - Schema -SwaggerBake works with your existing YML definitions and will not overwrite anything. +SwaggerBake works with your existing YML definitions and will not overwrite anything. By default, it uses +components > schemas > Exception as your Swagger documentations Exception schema. See the default +[swagger.yml](assets/swagger.yml) and `exceptionSchema` in [swagger_bake.php](assets/swagger_bake.php) for more info. ## Doc Blocks @@ -123,7 +125,7 @@ Method level annotation for adding query parameters. ```php /** - * @Swag\SwagQuery(name="queryParamName", type="string", required=false) + * @Swag\SwagQuery(name="queryParamName", type="string", description="string", required=false) */ public function index() {} ``` @@ -133,7 +135,7 @@ Method level annotation for adding form data fields. ```php /** - * @Swag\SwagForm(name="fieldName", type="string", required=false) + * @Swag\SwagForm(name="fieldName", type="string", description="string", required=false) */ public function index() {} ``` @@ -143,7 +145,7 @@ Method level annotation for adding header parameters. ```php /** - * @Swag\SwagHeader(name="X-HEAD-ATTRIBUTE", type="string", required=false) + * @Swag\SwagHeader(name="X-HEAD-ATTRIBUTE", type="string", description="string", required=false) */ public function index() {} ``` @@ -183,8 +185,7 @@ Method level annotation for describing custom content in request body. ```php /** - * @Swag\SwagRequestBodyContent(refEntity="#/components/schemas/Lead", mimeType="application/x-www-form-urlencoded") - * @Swag\SwagRequestBodyContent(refEntity="", mimeType="text/plain") + * @Swag\SwagRequestBodyContent(refEntity="#/components/schemas/Lead", mimeType="application/json") */ public function index() {} ``` @@ -225,7 +226,7 @@ Class level annotation for customizing Schema Attributes with @SwagEntityAttribu ```php /** - * @Swag\SwagEntityAttribute(name="modified", type="string", readOnly=true, required=false) + * @Swag\SwagEntityAttribute(name="modified", type="string", description="string", readOnly=true, required=false) */ class Employee extends Entity { ``` @@ -343,6 +344,11 @@ paths using CakePHPs route resource functionality. Read the [Cake Routing documentation](https://book.cakephp.org/4/en/development/routing.html) which describes in detail how to add, remove, modify, and alter routes. +#### Missing CSRF token body + +Either disable CSRF protection on your main route in `config/routes.php` or enable CSRF protection in Swagger +UI. The library does not currently support adding this in for you. + ## Reporting Issues This is a new library so please take some steps before reporting issues. You can copy & paste the JSON SwaggerBake diff --git a/assets/swagger.yml b/assets/swagger.yml index 94ca3730..ad5d65dc 100644 --- a/assets/swagger.yml +++ b/assets/swagger.yml @@ -9,3 +9,18 @@ servers: paths: definitions: + +components: + schemas: + Exception: + type: object + properties: + code: + type: integer + example: 500 + url: + type: string + example: /url/path + message: + type: string + example: Internal Error diff --git a/assets/swagger_bake.php b/assets/swagger_bake.php index b13e3759..e9fb2412 100644 --- a/assets/swagger_bake.php +++ b/assets/swagger_bake.php @@ -13,6 +13,8 @@ * * @var string $docType: Options are swagger and redoc, defaults: swagger * + * @var string $exceptionSchema: The name of your Exception schema in your swagger.yml definition file. + * * @var array $namespaces: Can be used if your controllers or entities exist in non-standard namespace such as a plugin */ return [ @@ -21,13 +23,17 @@ 'yml' => '/config/swagger.yml', 'json' => '/webroot/swagger.json', 'webPath' => '/swagger.json', - 'hotReload' => false, + 'hotReload' => \Cake\Core\Configure::read('debug'), + /** optional configurations below: **/ + /* 'docType' => 'swagger', + 'exceptionSchema' => 'Exception', 'namespaces' => [ 'controllers' => ['\App\\'], 'entities' => ['\App\\'], 'tables' => ['\App\\'] ] + */ ] ]; diff --git a/src/Command/ModelCommand.php b/src/Command/ModelCommand.php index 305d4404..8400df3d 100644 --- a/src/Command/ModelCommand.php +++ b/src/Command/ModelCommand.php @@ -25,7 +25,9 @@ class ModelCommand extends Command */ public function execute(Arguments $args, ConsoleIo $io) { - $io->out("Running..."); + $io->hr(); + $io->out("| SwaggerBake is checking your models..."); + $io->hr(); ValidateConfiguration::validate(); @@ -36,7 +38,11 @@ public function execute(Arguments $args, ConsoleIo $io) $models = $cakeModel->getModels(); if (empty($models)) { - return $io->warning('No models found'); + $io->out(); + $io->warning('No models were found that are associated with: ' . $config->getPrefix()); + $io->out("Have you added RESTful routes? Do you have models associated with those routes?"); + $io->out(); + return; } $header = ['Attribute','Data Type', 'Swagger Type','Default','Primary Key']; diff --git a/src/Command/RouteCommand.php b/src/Command/RouteCommand.php index e8d620bf..70131d3b 100644 --- a/src/Command/RouteCommand.php +++ b/src/Command/RouteCommand.php @@ -25,12 +25,14 @@ class RouteCommand extends Command */ public function execute(Arguments $args, ConsoleIo $io) { - $io->out("Running..."); + $io->hr(); + $io->out("| SwaggerBake is checking your routes..."); + $io->hr(); ValidateConfiguration::validate(); $output = [ - ['Route name', 'URI template', 'Defaults'], + ['Route name', 'URI template', 'Method(s)', 'Controller', 'Action', 'Plugin'], ]; $config = new Configuration(); @@ -39,15 +41,22 @@ public function execute(Arguments $args, ConsoleIo $io) $routes = $cakeRoute->getRoutes(); if (empty($routes)) { - $io->out("No routes were found for: $prefix"); - $io->out('https://book.cakephp.org/4/en/development/routing.html#restful-routing'); + $io->out(); + $io->warning("No routes were found for: $prefix"); + $io->out("Have you added RESTful routes? Do you have models associated with those routes?"); + $io->out(); return; } foreach ($routes as $route) { - $name = $route->options['_name'] ?? $route->getName(); - ksort($route->defaults); - $output[] = [$name, $route->template, json_encode($route->defaults)]; + $output[] = [ + $route->getName(), + $route->getTemplate(), + implode(', ', $route->getMethods()), + $route->getController(), + $route->getAction(), + $route->getPlugin(), + ]; } $io->helper('table')->output($output); diff --git a/src/Lib/AbstractParameter.php b/src/Lib/AbstractParameter.php index 8bbb3b1e..e2d86531 100644 --- a/src/Lib/AbstractParameter.php +++ b/src/Lib/AbstractParameter.php @@ -2,10 +2,10 @@ namespace SwaggerBake\Lib; -use Cake\Routing\Route\Route; use Doctrine\Common\Annotations\AnnotationReader; use ReflectionClass; use SwaggerBake\Lib\Exception\SwaggerBakeRunTimeException; +use SwaggerBake\Lib\Model\ExpressiveRoute; class AbstractParameter { @@ -18,14 +18,13 @@ class AbstractParameter protected $reflectionMethods; protected $config; - public function __construct(Route $route, Configuration $config) + public function __construct(ExpressiveRoute $route, Configuration $config) { $this->config = $config; $this->route = $route; - $defaults = (array) $this->route->defaults; - $this->actionName = $defaults['action']; - $this->className = $defaults['controller'] . 'Controller'; + $this->actionName = $this->route->getAction(); + $this->className = $this->route->getController() . 'Controller'; $this->controller = $this->getControllerFromNamespaces($this->className); $instance = new $this->controller; diff --git a/src/Lib/Annotation/SwagEntity.php b/src/Lib/Annotation/SwagEntity.php index 4ec8f2e9..00bf514e 100644 --- a/src/Lib/Annotation/SwagEntity.php +++ b/src/Lib/Annotation/SwagEntity.php @@ -13,6 +13,7 @@ */ class SwagEntity { + /** @var bool **/ public $isVisible; public function __construct(array $values) diff --git a/src/Lib/Annotation/SwagEntityAttribute.php b/src/Lib/Annotation/SwagEntityAttribute.php index f4c9a41b..7a020970 100644 --- a/src/Lib/Annotation/SwagEntityAttribute.php +++ b/src/Lib/Annotation/SwagEntityAttribute.php @@ -10,6 +10,7 @@ * @Attributes({ * @Attribute("name", type = "string"), * @Attribute("type", type = "string"), + * @Attribute("description", type = "string"), * @Attribute("readOnly", type = "bool"), * @Attribute("writeOnly", type = "bool"), * @Attribute("required", type = "bool"), @@ -17,10 +18,22 @@ */ class SwagEntityAttribute { + /** @var string */ public $name; + + /** @var string */ public $type; + + /** @var string */ + public $description; + + /** @var bool */ public $readOnly; + + /** @var bool */ public $writeOnly; + + /** @var bool */ public $required; public function __construct(array $values) @@ -30,12 +43,13 @@ public function __construct(array $values) } $values = array_merge( - ['type' => 'string', 'readOnly' => false, 'writeOnly' => false, 'required' => false], + ['type' => 'string', 'description' => '', 'readOnly' => false, 'writeOnly' => false, 'required' => false], $values ); $this->name = $values['name']; $this->type = $values['type']; + $this->description = $values['description']; $this->readOnly = $values['readOnly']; $this->writeOnly = $values['writeOnly']; $this->required = $values['required']; diff --git a/src/Lib/Annotation/SwagEntityAttributeHandler.php b/src/Lib/Annotation/SwagEntityAttributeHandler.php index d4a9ee96..d9e8e35b 100644 --- a/src/Lib/Annotation/SwagEntityAttributeHandler.php +++ b/src/Lib/Annotation/SwagEntityAttributeHandler.php @@ -11,6 +11,7 @@ public function getSchemaProperty(SwagEntityAttribute $annotation) : SchemaPrope $schemaProperty = new SchemaProperty(); $schemaProperty ->setName($annotation->name) + ->setDescription($annotation->description) ->setType($annotation->type) ->setReadOnly($annotation->readOnly) ->setWriteOnly($annotation->writeOnly) diff --git a/src/Lib/Annotation/SwagForm.php b/src/Lib/Annotation/SwagForm.php index 33f146de..b5c14a9a 100644 --- a/src/Lib/Annotation/SwagForm.php +++ b/src/Lib/Annotation/SwagForm.php @@ -12,13 +12,22 @@ * @Attributes({ * @Attribute("name", type = "string"), * @Attribute("type", type = "string"), - * @Attribute("required", type = "bool"), + * @Attribute("description", type="string"), + * @Attribute("required", type="boolean"), * }) */ class SwagForm { + /** @var string */ public $name; + + /** @var string */ public $type; + + /** @var string */ + public $description; + + /** @var bool */ public $required; public function __construct(array $values) @@ -27,7 +36,7 @@ public function __construct(array $values) throw new InvalidArgumentException('Name parameter is required'); } - $values = array_merge(['type' => 'string', 'required' => false], $values); + $values = array_merge(['type' => 'string', 'description' => '', 'required' => false], $values); if (!in_array($values['type'], OpenApiDataType::TYPES)) { $type = $values['type']; @@ -42,6 +51,7 @@ public function __construct(array $values) $this->name = $values['name']; $this->type = $values['type']; - $this->required = $values['required']; + $this->description = $values['description']; + $this->required = (bool) $values['required']; } } \ No newline at end of file diff --git a/src/Lib/Annotation/SwagFormHandler.php b/src/Lib/Annotation/SwagFormHandler.php index 4348ecea..c9a11328 100644 --- a/src/Lib/Annotation/SwagFormHandler.php +++ b/src/Lib/Annotation/SwagFormHandler.php @@ -10,6 +10,7 @@ public function getSchemaProperty(SwagForm $annotation) : SchemaProperty { $schemaProperty = new SchemaProperty(); $schemaProperty + ->setDescription($annotation->description) ->setName($annotation->name) ->setType($annotation->type) ->setRequired($annotation->required) diff --git a/src/Lib/Annotation/SwagHeader.php b/src/Lib/Annotation/SwagHeader.php index b242ce51..c7f725fd 100644 --- a/src/Lib/Annotation/SwagHeader.php +++ b/src/Lib/Annotation/SwagHeader.php @@ -10,13 +10,22 @@ * @Attributes({ * @Attribute("name", type = "string"), * @Attribute("type", type = "string"), + * @Attribute("description", type = "string"), * @Attribute("required", type = "bool"), * }) */ class SwagHeader { + /** @var string */ public $name; + + /** @var string */ public $type; + + /** @var string */ + public $description; + + /** @var bool */ public $required; public function __construct(array $values) @@ -25,10 +34,11 @@ public function __construct(array $values) throw new InvalidArgumentException('Name parameter is required'); } - $values = array_merge(['type' => 'string', 'required' => false], $values); + $values = array_merge(['type' => 'string', 'description' => '', 'required' => false], $values); $this->name = $values['name']; $this->type = $values['type']; - $this->required = $values['required']; + $this->description = $values['description']; + $this->required = (bool) $values['required']; } } \ No newline at end of file diff --git a/src/Lib/Annotation/SwagHeaderHandler.php b/src/Lib/Annotation/SwagHeaderHandler.php index f09b2b3b..7a31b0a9 100644 --- a/src/Lib/Annotation/SwagHeaderHandler.php +++ b/src/Lib/Annotation/SwagHeaderHandler.php @@ -12,6 +12,7 @@ public function getHeaderParameters(SwagHeader $annotation) : Parameter $parameter = new Parameter(); $parameter ->setName($annotation->name) + ->setDescription($annotation->description) ->setAllowEmptyValue(false) ->setDeprecated(false) ->setRequired($annotation->required) diff --git a/src/Lib/Annotation/SwagOperation.php b/src/Lib/Annotation/SwagOperation.php index 568a4563..251dada4 100644 --- a/src/Lib/Annotation/SwagOperation.php +++ b/src/Lib/Annotation/SwagOperation.php @@ -13,6 +13,7 @@ */ class SwagOperation { + /** @var bool */ public $isVisible; public function __construct(array $values) diff --git a/src/Lib/Annotation/SwagPath.php b/src/Lib/Annotation/SwagPath.php index 0b36dd97..7ab93281 100644 --- a/src/Lib/Annotation/SwagPath.php +++ b/src/Lib/Annotation/SwagPath.php @@ -13,6 +13,7 @@ */ class SwagPath { + /** @var bool */ public $isVisible; public function __construct(array $values) diff --git a/src/Lib/Annotation/SwagQuery.php b/src/Lib/Annotation/SwagQuery.php index c9f9cc5e..a9357518 100644 --- a/src/Lib/Annotation/SwagQuery.php +++ b/src/Lib/Annotation/SwagQuery.php @@ -12,13 +12,22 @@ * @Attributes({ * @Attribute("name", type="string"), * @Attribute("type", type="string"), + * @Attribute("description", type="string"), * @Attribute("required", type="boolean"), * }) */ class SwagQuery { + /** @var string */ public $name; + + /** @var string */ public $type; + + /** @var string */ + public $description; + + /** @var bool */ public $required; public function __construct(array $values) @@ -27,7 +36,7 @@ public function __construct(array $values) throw new InvalidArgumentException('Name parameter is required'); } - $values = array_merge(['type' => 'string', 'required' => false], $values); + $values = array_merge(['type' => 'string', 'description' => '', 'required' => false], $values); if (!in_array($values['type'], OpenApiDataType::TYPES)) { $type = $values['type']; @@ -40,6 +49,7 @@ public function __construct(array $values) $this->name = $values['name']; $this->type = $values['type']; - $this->required = $values['required']; + $this->description = $values['description']; + $this->required = (bool) $values['required']; } } \ No newline at end of file diff --git a/src/Lib/Annotation/SwagQueryHandler.php b/src/Lib/Annotation/SwagQueryHandler.php index f80c3ec2..b5eaf05f 100644 --- a/src/Lib/Annotation/SwagQueryHandler.php +++ b/src/Lib/Annotation/SwagQueryHandler.php @@ -12,6 +12,7 @@ public function getQueryParameter(SwagQuery $annotation) : Parameter $parameter = new Parameter(); $parameter ->setName($annotation->name) + ->setDescription($annotation->description) ->setAllowEmptyValue(false) ->setDeprecated(false) ->setRequired($annotation->required) diff --git a/src/Lib/Annotation/SwagRequestBody.php b/src/Lib/Annotation/SwagRequestBody.php index f0db8d64..4e3ea667 100644 --- a/src/Lib/Annotation/SwagRequestBody.php +++ b/src/Lib/Annotation/SwagRequestBody.php @@ -15,8 +15,13 @@ */ class SwagRequestBody { + /** @var string */ public $description; + + /** @var bool */ public $required; + + /** @var bool */ public $ignoreCakeSchema; public function __construct(array $values) diff --git a/src/Lib/Annotation/SwagRequestBodyContent.php b/src/Lib/Annotation/SwagRequestBodyContent.php index 1d43244c..9a2b30ba 100644 --- a/src/Lib/Annotation/SwagRequestBodyContent.php +++ b/src/Lib/Annotation/SwagRequestBodyContent.php @@ -14,14 +14,16 @@ */ class SwagRequestBodyContent { + /** @var string */ public $refEntity; + + /** @var string */ public $mimeType; - public $ignoreCakeSchema; public function __construct(array $values) { $values = array_merge(['refEntity' => '', 'mimeType' => 'text/plain'], $values); $this->refEntity = $values['refEntity']; - $this->mimeType = (bool) $values['mimeType']; + $this->mimeType = $values['mimeType']; } } \ No newline at end of file diff --git a/src/Lib/Annotation/SwagResponseSchema.php b/src/Lib/Annotation/SwagResponseSchema.php index fe7a034a..5e705866 100644 --- a/src/Lib/Annotation/SwagResponseSchema.php +++ b/src/Lib/Annotation/SwagResponseSchema.php @@ -15,8 +15,13 @@ */ class SwagResponseSchema { + /** @var string */ public $refEntity; + + /** @var int */ public $httpCode = 200; + + /** @var string */ public $description; public function __construct(array $values) diff --git a/src/Lib/Annotation/SwagSecurity.php b/src/Lib/Annotation/SwagSecurity.php index 8eea45be..793dc1b5 100644 --- a/src/Lib/Annotation/SwagSecurity.php +++ b/src/Lib/Annotation/SwagSecurity.php @@ -14,7 +14,10 @@ */ class SwagSecurity { + /** @var string */ public $name; + + /** @var string */ public $scopes; public function __construct(array $values) diff --git a/src/Lib/CakeModel.php b/src/Lib/CakeModel.php index ebabf374..fcc2a794 100644 --- a/src/Lib/CakeModel.php +++ b/src/Lib/CakeModel.php @@ -18,8 +18,13 @@ */ class CakeModel { + /** @var CakeRoute */ private $cakeRoute; + + /** @var string */ private $prefix; + + /** @var Configuration */ private $config; public function __construct(CakeRoute $cakeRoute, Configuration $config) @@ -29,6 +34,11 @@ public function __construct(CakeRoute $cakeRoute, Configuration $config) $this->config = $config; } + /** + * Gets an array of ExpressiveModel + * + * @return ExpressiveModel[] + */ public function getModels() : array { $return = []; @@ -105,11 +115,10 @@ private function getTablesFromRoutes(array $routes) : array { $return = []; foreach ($routes as $route) { - $controllerName = $this->cakeRoute->getControllerFromRoute($route); - if (empty($controllerName)) { + if (empty($route->getController())) { continue; } - $return[] = Inflector::underscore($controllerName); + $return[] = Inflector::underscore($route->getController()); } return array_unique($return); } diff --git a/src/Lib/CakeRoute.php b/src/Lib/CakeRoute.php index d252a619..dfc15696 100644 --- a/src/Lib/CakeRoute.php +++ b/src/Lib/CakeRoute.php @@ -1,9 +1,8 @@ router = $router; $this->prefix = $config->getPrefix(); + $this->prefixLength = strlen($this->prefix); } + /** + * Gets an array of Route + * + * @return ExpressiveRoute[] + */ public function getRoutes() : array { if (empty($this->prefix) || !filter_var('http://foo.com' . $this->prefix, FILTER_VALIDATE_URL)) { throw new InvalidArgumentException('route prefix is invalid'); } - $length = strlen($this->prefix); - - return array_filter($this->router::routes(), function ($route) use ($length) { - if (substr($route->template, 0, $length) != $this->prefix) { - return null; - } - if (substr($route->template, $length) == '') { - return null; - } - return true; + $filteredRoutes = array_filter($this->router::routes(), function ($route) { + return $this->isRouteAllowed($route); }); + + $routes = []; + + foreach ($filteredRoutes as $route) { + $routes[$route->getName()] = $this->createExpressiveRouteFromRoute($route); + } + + ksort($routes); + + return $routes; } - public function getControllerFromRoute(Route $route) : ?string + private function createExpressiveRouteFromRoute(Route $route) : ExpressiveRoute { $defaults = (array) $route->defaults; - if (!isset($defaults['controller'])) { - return null; + $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) + ; + } + + private function isRouteAllowed(Route $route) : bool + { + if (substr($route->template, 0, $this->prefixLength) != $this->prefix) { + return false; + } + if (substr($route->template, $this->prefixLength) == '') { + return false; + } + + $defaults = (array) $route->defaults; + + if (!isset($defaults['_method']) || empty($defaults['_method'])) { + return false; + } + + if (isset($defaults['plugin']) && in_array($defaults['plugin'], self::EXCLUDED_PLUGINS)) { + return false; } - return $defaults['controller']; + return true; } } diff --git a/src/Lib/Configuration.php b/src/Lib/Configuration.php index ff22473e..f71728bf 100644 --- a/src/Lib/Configuration.php +++ b/src/Lib/Configuration.php @@ -24,6 +24,7 @@ public function __construct($config = [], $root = ROOT) [ 'docType' => 'swagger', 'hotReload' => false, + 'exceptionSchema' => 'Exception', 'namespaces' => [ 'controllers' => ['\App\\'], 'entities' => ['\App\\'], @@ -89,6 +90,11 @@ public function getDocType() : string return strtolower($this->get('docType')); } + public function getExceptionSchema() : string + { + return $this->get('exceptionSchema'); + } + public function getLayout(?string $doctype = null) : string { $doctype = empty($doctype) ? $this->getDocType() : $doctype; diff --git a/src/Lib/Factory/PathFactory.php b/src/Lib/Factory/PathFactory.php index fa8b2587..dd33706b 100644 --- a/src/Lib/Factory/PathFactory.php +++ b/src/Lib/Factory/PathFactory.php @@ -2,7 +2,6 @@ namespace SwaggerBake\Lib\Factory; -use Cake\Routing\Route\Route; use Cake\Utility\Inflector; use Exception; use phpDocumentor\Reflection\DocBlock; @@ -12,9 +11,11 @@ use SwaggerBake\Lib\Configuration; use SwaggerBake\Lib\Exception\SwaggerBakeRunTimeException; use SwaggerBake\Lib\ExceptionHandler; +use SwaggerBake\Lib\Model\ExpressiveRoute; use SwaggerBake\Lib\OpenApi\OperationExternalDoc; use SwaggerBake\Lib\OpenApi\Path; use SwaggerBake\Lib\OpenApi\Parameter; +use SwaggerBake\Lib\OpenApi\RequestBody; use SwaggerBake\Lib\OpenApi\Response; use SwaggerBake\Lib\OpenApi\Schema; use SwaggerBake\Lib\Utility\AnnotationUtility; @@ -28,7 +29,7 @@ class PathFactory private $prefix = ''; private $config; - public function __construct(Route $route, Configuration $config) + public function __construct(ExpressiveRoute $route, Configuration $config) { $this->config = $config; $this->route = $route; @@ -44,19 +45,21 @@ public function __construct(Route $route, Configuration $config) public function create() : ?Path { $path = new Path(); - $defaults = (array) $this->route->defaults; - if (empty($defaults['_method'])) { + if (empty($this->route->getMethods())) { return null; } - if (!$this->isControllerVisible($defaults['controller'])) { + if (!$this->isControllerVisible($this->route->getController())) { return null; } - foreach ((array) $defaults['_method'] as $method) { + foreach ($this->route->getMethods() as $method) { - $methodAnnotations = $this->getMethodAnnotations($defaults['controller'], $defaults['action']); + $methodAnnotations = $this->getMethodAnnotations( + $this->route->getController(), + $this->route->getAction() + ); if (!$this->isMethodVisible($methodAnnotations)) { continue; @@ -69,7 +72,7 @@ public function create() : ?Path ->setSummary($this->dockBlock ? $this->dockBlock->getSummary() : '') ->setDescription($this->dockBlock ? $this->dockBlock->getDescription() : '') ->setTags([ - Inflector::humanize(Inflector::underscore($defaults['controller'])) + Inflector::humanize(Inflector::underscore($this->route->getController())) ]) ->setParameters($this->getPathParameters()) ->setDeprecated($this->isDeprecated()) @@ -92,19 +95,24 @@ function ($piece) { } return $piece; }, - explode('/', $this->route->template) + explode('/', $this->route->getTemplate()) ); - $length = strlen($this->prefix); + if ($this->prefix == '/') { + return implode('/', $pieces); + } - return substr(implode('/', $pieces), $length); + return substr( + implode('/', $pieces), + strlen($this->prefix) + ); } private function getPathParameters() : array { $return = []; - $pieces = explode('/', $this->route->template); + $pieces = explode('/', $this->route->getTemplate()); $results = array_filter($pieces, function ($piece) { return substr($piece, 0, 1) == ':' ? true : null; }); @@ -171,7 +179,6 @@ private function withResponses(Path $path, array $annotations) : Path ); } - return $path; } @@ -181,16 +188,14 @@ private function withRequestBody(Path $path, array $annotations) : Path return $path; } + $requestBody = new RequestBody(); + foreach ($annotations as $annotation) { if ($annotation instanceof SwagAnnotation\SwagRequestBody) { $requestBody = (new SwagAnnotation\SwagRequestBodyHandler())->getResponse($annotation); } } - if (!isset($requestBody)) { - return $path; - } - foreach ($annotations as $annotation) { if ($annotation instanceof SwagAnnotation\SwagRequestBodyContent) { $requestBody->pushContent( @@ -208,14 +213,12 @@ private function withRequestBody(Path $path, array $annotations) : Path private function getDocBlock() : ?DocBlock { - $defaults = (array) $this->route->defaults; - - if (!isset($defaults['controller'])) { + if (empty($this->route->getController())) { return null; } - $className = $defaults['controller'] . 'Controller'; - $methodName = $defaults['action']; + $className = $this->route->getController() . 'Controller'; + $methodName = $this->route->getAction(); $controller = $this->getControllerFromNamespaces($className); diff --git a/src/Lib/Model/ExpressiveRoute.php b/src/Lib/Model/ExpressiveRoute.php new file mode 100644 index 00000000..edb80f31 --- /dev/null +++ b/src/Lib/Model/ExpressiveRoute.php @@ -0,0 +1,83 @@ +name; + } + + public function setName($name) + { + $this->name = $name; + return $this; + } + + public function getPlugin() + { + return $this->plugin; + } + + public function setPlugin($plugin) + { + $this->plugin = $plugin; + return $this; + } + + public function getController() + { + return $this->controller; + } + + public function setController($controller) + { + $this->controller = $controller; + return $this; + } + + public function getAction() + { + return $this->action; + } + + public function setAction($action) + { + $this->action = $action; + return $this; + } + + public function getMethods(): array + { + return $this->methods; + } + + public function setMethods(array $methods): ExpressiveRoute + { + $this->methods = $methods; + return $this; + } + + public function getTemplate() + { + return $this->template; + } + + public function setTemplate($template) + { + $this->template = $template; + return $this; + } +} \ No newline at end of file diff --git a/src/Lib/OpenApi/Content.php b/src/Lib/OpenApi/Content.php index fe439407..671e4aee 100644 --- a/src/Lib/OpenApi/Content.php +++ b/src/Lib/OpenApi/Content.php @@ -14,6 +14,10 @@ public function toArray() : array { $vars = get_object_vars($this); unset($vars['mimeType']); + if (is_string($this->schema)) { + unset($vars['schema']); + $vars['schema']['$ref'] = $this->schema; + } return $vars; } @@ -49,10 +53,12 @@ public function getSchema() : Schema } /** - * @param mixed $schema + * Can be either a schema $ref string such as '#/components/schemas/Pet' or a Schema instance. + * + * @param string|Schema $schema * @return Content */ - public function setSchema(Schema $schema) : Content + public function setSchema($schema) : Content { $this->schema = $schema; return $this; diff --git a/src/Lib/OpenApi/Parameter.php b/src/Lib/OpenApi/Parameter.php index 7ba49321..e705f0e9 100644 --- a/src/Lib/OpenApi/Parameter.php +++ b/src/Lib/OpenApi/Parameter.php @@ -12,12 +12,25 @@ */ class Parameter implements JsonSerializable { + /** @var string **/ private $name = ''; + + /** @var string **/ private $in = ''; + + /** @var string **/ private $description = ''; + + /** @var bool **/ private $required = false; + + /** @var Schema **/ private $schema; + + /** @var bool **/ private $deprecated = false; + + /** @var bool **/ private $allowEmptyValue = true; public function toArray() : array @@ -27,7 +40,11 @@ public function toArray() : array public function jsonSerialize() { - return $this->toArray(); + $vars = $this->toArray(); + if (empty($vars['description'])) { + unset($vars['description']); + } + return $vars; } /** diff --git a/src/Lib/OpenApi/Path.php b/src/Lib/OpenApi/Path.php index dacedce9..058cc948 100644 --- a/src/Lib/OpenApi/Path.php +++ b/src/Lib/OpenApi/Path.php @@ -26,13 +26,13 @@ class Path private $security = []; private $deprecated = false; - public function toArray() : array + public function toArray(): array { $vars = get_object_vars($this); unset($vars['type']); unset($vars['path']); - if (in_array($this->type, ['get','delete'])) { + if (in_array($this->type, ['get', 'delete'])) { unset($vars['requestBody']); } if (empty($vars['security'])) { @@ -45,6 +45,15 @@ public function toArray() : array 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 */ @@ -187,7 +196,7 @@ public function setRequestBody(RequestBody $requestBody) : Path } /** - * @return array + * @return Response[] */ public function getResponses(): array { diff --git a/src/Lib/OpenApi/Schema.php b/src/Lib/OpenApi/Schema.php index 77d2fa9d..a71c6f81 100644 --- a/src/Lib/OpenApi/Schema.php +++ b/src/Lib/OpenApi/Schema.php @@ -7,12 +7,24 @@ class Schema implements JsonSerializable { + /** @var string */ private $name = ''; + + /** @var string */ private $description = ''; + + /** @var string */ private $type = ''; + + /** @var array */ private $required = []; + + /** @var array */ private $properties = []; + /** @var array */ + private $items = []; + public function toArray() : array { $vars = get_object_vars($this); @@ -25,6 +37,9 @@ public function toArray() : array if (empty($vars['properties'])) { unset($vars['properties']); } + if (empty($vars['items'])) { + unset($vars['items']); + } return $vars; } @@ -149,4 +164,24 @@ public function setDescription(string $description): Schema $this->description = $description; return $this; } + + /** + * @return array + */ + public function getItems(): array + { + return $this->items; + } + + /** + * @param array $items + * @return Schema + */ + public function setItems(array $items): Schema + { + $this->items = $items; + return $this; + } + + } \ No newline at end of file diff --git a/src/Lib/OpenApi/SchemaProperty.php b/src/Lib/OpenApi/SchemaProperty.php index 4bfdef38..ef0cc1d9 100644 --- a/src/Lib/OpenApi/SchemaProperty.php +++ b/src/Lib/OpenApi/SchemaProperty.php @@ -7,11 +7,28 @@ class SchemaProperty implements JsonSerializable { + /** @var string */ private $name = ''; + + /** @var string */ private $type = ''; + + /** @var string */ private $format = ''; + + /** @var string */ + private $example = ''; + + /** @var string */ + private $description = ''; + + /** @var bool */ private $readOnly = false; + + /** @var bool */ private $writeOnly = false; + + /** @var bool */ private $required = false; public function toArray() : array @@ -19,6 +36,14 @@ public function toArray() : array $vars = get_object_vars($this); unset($vars['name']); unset($vars['required']); + + if (empty($vars['example'])) { + unset($vars['example']); + } + if (empty($vars['description'])) { + unset($vars['description']); + } + return $vars; } @@ -134,4 +159,40 @@ public function setRequired(bool $required): SchemaProperty $this->required = $required; return $this; } + + /** + * @return string + */ + public function getExample(): string + { + return $this->example; + } + + /** + * @param string $example + * @return SchemaProperty + */ + public function setExample(string $example): SchemaProperty + { + $this->example = $example; + return $this; + } + + /** + * @return string + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * @param string $description + * @return SchemaProperty + */ + public function setDescription(string $description): SchemaProperty + { + $this->description = $description; + return $this; + } } \ No newline at end of file diff --git a/src/Lib/RequestBodyBuilder.php b/src/Lib/RequestBodyBuilder.php index cb183b2d..08c89121 100644 --- a/src/Lib/RequestBodyBuilder.php +++ b/src/Lib/RequestBodyBuilder.php @@ -3,17 +3,16 @@ namespace SwaggerBake\Lib; -use Cake\Routing\Route\Route; use Cake\Utility\Inflector; +use SwaggerBake\Lib\Model\ExpressiveRoute; use SwaggerBake\Lib\OpenApi\Content; use SwaggerBake\Lib\OpenApi\Path; use SwaggerBake\Lib\OpenApi\RequestBody; use SwaggerBake\Lib\OpenApi\Schema; -use SwaggerBake\Lib\OpenApi\SchemaProperty; class RequestBodyBuilder { - public function __construct(Path $path, Swagger $swagger, Route $route) + public function __construct(Path $path, Swagger $swagger, ExpressiveRoute $route) { $this->path = $path; $this->route = $route; @@ -47,13 +46,11 @@ public function build() : ?RequestBody return null; } - $content = new Content(); - $content + $content = (new Content()) ->setMimeType('application/x-www-form-urlencoded') ->setSchema($schema); ; - $requestBody ->pushContent($content) ->setRequired(true) diff --git a/src/Lib/Security.php b/src/Lib/Security.php index f0860245..0bf9538a 100644 --- a/src/Lib/Security.php +++ b/src/Lib/Security.php @@ -2,7 +2,6 @@ namespace SwaggerBake\Lib; - use SwaggerBake\Lib\Annotation as SwagAnnotation; class Security extends AbstractParameter diff --git a/src/Lib/Swagger.php b/src/Lib/Swagger.php index 236a7ca8..dc83085d 100644 --- a/src/Lib/Swagger.php +++ b/src/Lib/Swagger.php @@ -2,13 +2,14 @@ namespace SwaggerBake\Lib; -use Cake\Routing\Route\Route; use Cake\Utility\Inflector; use SwaggerBake\Lib\Exception\SwaggerBakeRunTimeException; use SwaggerBake\Lib\Factory as Factory; +use SwaggerBake\Lib\Model\ExpressiveRoute; use SwaggerBake\Lib\OpenApi\Path; use SwaggerBake\Lib\OpenApi\Response; use SwaggerBake\Lib\OpenApi\Schema; +use SwaggerBake\Lib\OpenApi\SchemaProperty; use Symfony\Component\Yaml\Yaml; class Swagger @@ -23,16 +24,7 @@ public function __construct(CakeModel $cakeModel) $this->cakeModel = $cakeModel; $this->cakeRoute = $cakeModel->getCakeRoute(); $this->config = $cakeModel->getConfig(); - - $array = Yaml::parseFile($this->config->getYml()); - if (!isset($array['paths'])) { - $array['paths'] = []; - } - if (!isset($array['components']['schemas'])) { - $array['components']['schemas'] = []; - } - - $this->array = $array; + $this->buildFromDefaults(); } /** @@ -59,8 +51,13 @@ public function getArray(): array } } - ksort($this->array['paths']); - ksort($this->array['components']['schemas']); + ksort($this->array['paths'], SORT_STRING); + uksort($this->array['components']['schemas'], function ($a, $b) { + return strcasecmp( + preg_replace('/\s+/', '', $a), + preg_replace('/\s+/', '', $b) + ); + }); if (empty($this->array['components']['schemas'])) { unset($this->array['components']['schemas']); @@ -170,6 +167,9 @@ public function getConfig() : Configuration return $this->config; } + /** + * Builds schemas from cake models + */ private function buildSchemas(): void { $schemaFactory = new Factory\SchemaFactory($this->config); @@ -187,6 +187,9 @@ private function buildSchemas(): void } } + /** + * Builds paths from cake routes + */ private function buildPaths(): void { $routes = $this->cakeRoute->getRoutes(); @@ -210,13 +213,27 @@ private function buildPaths(): void } } - private function pathWithSecurity(Path $path, Route $route) : Path + /** + * Sets security on a path + * + * @param Path $path + * @param ExpressiveRoute $route + * @return Path + */ + private function pathWithSecurity(Path $path, ExpressiveRoute $route) : Path { $path->setSecurity((new Security($route, $this->config))->getPathSecurity()); return $path; } - private function pathWithParameters(Path $path, Route $route) : Path + /** + * 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) { @@ -230,29 +247,73 @@ private function pathWithParameters(Path $path, Route $route) : Path return $path; } + /** + * Sets responses on a path + * + * @param Path $path + * @return Path + */ private function pathWithResponses(Path $path) : Path { foreach ($path->getTags() as $tag) { $className = Inflector::classify($tag); + if ($path->hasSuccessResponseCode() || !$this->getSchemaByName($className)) { + continue; + } - if (!$path->getResponseByCode(200) && $this->getSchemaByName($className)) { - $response = new Response(); - $response - ->setSchemaRef('#/components/schemas/' . $className) + 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()) + ->setSchemaRef('#/components/schemas/' . $tag) ->setCode(200); $path->pushResponse($response); + continue; } + + $response = (new Response()) + ->setSchemaRef('#/components/schemas/' . $className) + ->setCode(200); + $path->pushResponse($response); } - if (!$path->getResponseByCode(200)) { + if (!$path->hasSuccessResponseCode()) { $path->pushResponse((new Response())->setCode(200)); } + $exceptionSchema = $this->getSchemaByName($this->getConfig()->getExceptionSchema()); + if (!$exceptionSchema) { + return $path; + } + + foreach ($path->getResponses() as $response) { + if ($response->getCode() < 400) { + continue; + } + $path->pushResponse( + $response->setSchemaRef('#/components/schemas/' . $exceptionSchema->getName()) + ); + } + return $path; } - private function pathWithRequestBody(Path $path, Route $route) : Path + /** + * 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) { @@ -261,6 +322,44 @@ private function pathWithRequestBody(Path $path, Route $route) : Path return $path; } + /** + * Constructs the primary array used in this class from pre-defined swagger.yml + */ + private function buildFromDefaults() : void + { + $array = Yaml::parseFile($this->config->getYml()); + if (!isset($array['paths'])) { + $array['paths'] = []; + } + if (!isset($array['components']['schemas'])) { + $array['components']['schemas'] = []; + } + + foreach ($array['components']['schemas'] as $schemaName => $schemaVar) { + + $schema = (new Schema()) + ->setName($schemaName) + ->setType($schemaVar['type']) + ->setDescription($schemaVar['description'] ?? ''); + + $schemaVar['properties'] = $schemaVar['properties'] ?? []; + + foreach ($schemaVar['properties'] as $propertyName => $propertyVar) { + $property = (new SchemaProperty()) + ->setType($propertyVar['type']) + ->setName($propertyName) + ->setFormat($propertyVar['type'] ?? '') + ->setExample($propertyVar['example'] ?? '') + ; + $schema->pushProperty($property); + } + + $array['components']['schemas'][$schemaName] = $schema; + } + + $this->array = $array; + } + public function __toString(): string { return $this->toString(); diff --git a/src/Lib/Utility/DataTypeConversion.php b/src/Lib/Utility/DataTypeConversion.php index 9b5e8362..e5df5398 100644 --- a/src/Lib/Utility/DataTypeConversion.php +++ b/src/Lib/Utility/DataTypeConversion.php @@ -15,6 +15,11 @@ public static function convert(string $type) : string case 'biginteger': case 'mediuminteger': return 'integer'; + case 'decimal': + case 'float': + return 'number'; + case 'uuid': + case 'text': case 'varchar': case 'char': case 'date': diff --git a/tests/TestCase/Lib/SwaggerEntityTest.php b/tests/TestCase/Lib/SwaggerEntityTest.php index 6b6ea3d8..ecd76c49 100644 --- a/tests/TestCase/Lib/SwaggerEntityTest.php +++ b/tests/TestCase/Lib/SwaggerEntityTest.php @@ -35,17 +35,13 @@ public function setUp(): void }); $this->router = $router; - AnnotationLoader::load(); - } - - public function testEntityExists() - { - $config = new Configuration([ + $this->config = new Configuration([ 'prefix' => '/api', 'yml' => '/config/swagger-bare-bones.yml', 'json' => '/webroot/swagger.json', 'webPath' => '/swagger.json', 'hotReload' => false, + 'exceptionSchema' => 'Exception', 'namespaces' => [ 'controllers' => ['\SwaggerBakeTest\App\\'], 'entities' => ['\SwaggerBakeTest\App\\'], @@ -53,9 +49,14 @@ public function testEntityExists() ] ], SWAGGER_BAKE_TEST_APP); - $cakeRoute = new CakeRoute($this->router, $config); + AnnotationLoader::load(); + } + + public function testEntityExists() + { + $cakeRoute = new CakeRoute($this->router, $this->config); - $swagger = new Swagger(new CakeModel($cakeRoute, $config)); + $swagger = new Swagger(new CakeModel($cakeRoute, $this->config)); $arr = json_decode($swagger->toString(), true); @@ -64,22 +65,9 @@ public function testEntityExists() public function testEntityInvisible() { - $config = new Configuration([ - 'prefix' => '/api', - 'yml' => '/config/swagger-bare-bones.yml', - 'json' => '/webroot/swagger.json', - 'webPath' => '/swagger.json', - 'hotReload' => false, - 'namespaces' => [ - 'controllers' => ['\SwaggerBakeTest\App\\'], - 'entities' => ['\SwaggerBakeTest\App\\'], - 'tables' => ['\SwaggerBakeTest\App\\'], - ] - ], SWAGGER_BAKE_TEST_APP); - - $cakeRoute = new CakeRoute($this->router, $config); + $cakeRoute = new CakeRoute($this->router, $this->config); - $swagger = new Swagger(new CakeModel($cakeRoute, $config)); + $swagger = new Swagger(new CakeModel($cakeRoute, $this->config)); $arr = json_decode($swagger->toString(), true); @@ -88,21 +76,9 @@ public function testEntityInvisible() public function testEntityAttribute() { - $config = new Configuration([ - 'prefix' => '/api', - 'yml' => '/config/swagger-bare-bones.yml', - 'json' => '/webroot/swagger.json', - 'webPath' => '/swagger.json', - 'hotReload' => false, - 'namespaces' => [ - 'controllers' => ['\SwaggerBakeTest\App\\'], - 'entities' => ['\SwaggerBakeTest\App\\'] - ] - ], SWAGGER_BAKE_TEST_APP); - - $cakeRoute = new CakeRoute($this->router, $config); + $cakeRoute = new CakeRoute($this->router, $this->config); - $swagger = new Swagger(new CakeModel($cakeRoute, $config)); + $swagger = new Swagger(new CakeModel($cakeRoute, $this->config)); $arr = json_decode($swagger->toString(), true); diff --git a/tests/TestCase/Lib/SwaggerOperationTest.php b/tests/TestCase/Lib/SwaggerOperationTest.php index 3c7e6eb0..6dc8a426 100644 --- a/tests/TestCase/Lib/SwaggerOperationTest.php +++ b/tests/TestCase/Lib/SwaggerOperationTest.php @@ -59,6 +59,7 @@ public function setUp(): void 'json' => '/webroot/swagger.json', 'webPath' => '/swagger.json', 'hotReload' => false, + 'exceptionSchema' => 'Exception', 'namespaces' => [ 'controllers' => ['\SwaggerBakeTest\App\\'], 'entities' => ['\SwaggerBakeTest\App\\'], diff --git a/tests/TestCase/Lib/SwaggerPathTest.php b/tests/TestCase/Lib/SwaggerPathTest.php index 596eea35..5e60cc0f 100644 --- a/tests/TestCase/Lib/SwaggerPathTest.php +++ b/tests/TestCase/Lib/SwaggerPathTest.php @@ -46,6 +46,7 @@ public function testPathInvisible() 'json' => '/webroot/swagger.json', 'webPath' => '/swagger.json', 'hotReload' => false, + 'exceptionSchema' => 'Exception', 'namespaces' => [ 'controllers' => ['\SwaggerBakeTest\App\\'], 'entities' => ['\SwaggerBakeTest\App\\'], diff --git a/tests/TestCase/Lib/SwaggerSchemaTest.php b/tests/TestCase/Lib/SwaggerSchemaTest.php index 033a976c..cbc656a5 100644 --- a/tests/TestCase/Lib/SwaggerSchemaTest.php +++ b/tests/TestCase/Lib/SwaggerSchemaTest.php @@ -35,6 +35,7 @@ public function setUp(): void 'json' => '/webroot/swagger.json', 'webPath' => '/swagger.json', 'hotReload' => false, + 'exceptionSchema' => 'Exception', 'namespaces' => [ 'controllers' => ['\SwaggerBakeTest\App\\'], 'entities' => ['\SwaggerBakeTest\App\\'], diff --git a/tests/TestCase/Lib/SwaggerTest.php b/tests/TestCase/Lib/SwaggerTest.php index b41b1d2c..a1a29c8b 100644 --- a/tests/TestCase/Lib/SwaggerTest.php +++ b/tests/TestCase/Lib/SwaggerTest.php @@ -50,23 +50,26 @@ public function setUp(): void }); $this->router = $router; - AnnotationLoader::load(); - } - - public function testGetArrayWithExistingPathsAndSchema() - { - $config = new Configuration([ + $this->config = [ 'prefix' => '/api', 'yml' => '/config/swagger-with-existing.yml', 'json' => '/webroot/swagger.json', 'webPath' => '/swagger.json', 'hotReload' => false, + 'exceptionSchema' => 'Exception', 'namespaces' => [ 'controllers' => ['\SwaggerBakeTest\App\\'], 'entities' => ['\SwaggerBakeTest\App\\'], 'tables' => ['\SwaggerBakeTest\App\\'], ] - ], SWAGGER_BAKE_TEST_APP); + ]; + + AnnotationLoader::load(); + } + + public function testGetArrayWithExistingPathsAndSchema() + { + $config = new Configuration($this->config, SWAGGER_BAKE_TEST_APP); $cakeRoute = new CakeRoute($this->router, $config); @@ -80,18 +83,9 @@ public function testGetArrayWithExistingPathsAndSchema() public function testGetArrayFromBareBones() { - $config = new Configuration([ - 'prefix' => '/api', - 'yml' => '/config/swagger-bare-bones.yml', - 'json' => '/webroot/swagger.json', - 'webPath' => '/swagger.json', - 'hotReload' => false, - 'namespaces' => [ - 'controllers' => ['\SwaggerBakeTest\App\\'], - 'entities' => ['\SwaggerBakeTest\App\\'], - 'tables' => ['\SwaggerBakeTest\App\\'], - ] - ], SWAGGER_BAKE_TEST_APP); + $vars = $this->config; + $vars['yml'] = '/config/swagger-bare-bones.yml'; + $config = new Configuration($vars, SWAGGER_BAKE_TEST_APP); $cakeRoute = new CakeRoute($this->router, $config);