diff --git a/Annotation/ApiDoc.php b/Annotation/ApiDoc.php index 3680ec5..90fc24a 100644 --- a/Annotation/ApiDoc.php +++ b/Annotation/ApiDoc.php @@ -135,6 +135,21 @@ class ApiDoc */ private $statusCodes = array(); + /** + * @var string|null + */ + private $resourceDescription = null; + + /** + * @var array + */ + private $responseMap = array(); + + /** + * @var array + */ + private $parsedResponseMap = array(); + /** * @var array */ @@ -241,6 +256,17 @@ class ApiDoc if (isset($data['https'])) { $this->https = $data['https']; } + + if (isset($data['resourceDescription'])) { + $this->resourceDescription = $data['resourceDescription']; + } + + if (isset($data['responseMap'])) { + $this->responseMap = $data['responseMap']; + if (isset($this->responseMap[200])) { + $this->output = $this->responseMap[200]; + } + } } /** @@ -606,6 +632,10 @@ class ApiDoc $data['tags'] = $tags; } + if ($resourceDescription = $this->resourceDescription) { + $data['resourceDescription'] = $resourceDescription; + } + $data['https'] = $this->https; $data['authentication'] = $this->authentication; $data['authenticationRoles'] = $this->authenticationRoles; @@ -613,4 +643,45 @@ class ApiDoc return $data; } + + /** + * @return null|string + */ + public function getResourceDescription() + { + return $this->resourceDescription; + } + + /** + * @return array + */ + public function getResponseMap() + { + if (!isset($this->responseMap[200]) && null !== $this->output) { + $this->responseMap[200] = $this->output; + } + + return $this->responseMap; + } + + /** + * @return array + */ + public function getParsedResponseMap() + { + return $this->parsedResponseMap; + } + + /** + * @param $model + * @param $type + * @param int $statusCode + */ + public function setResponseForStatusCode($model, $type, $statusCode = 200) + { + $this->parsedResponseMap[$statusCode] = array('type' => $type, 'model' => $model); + if ($statusCode == 200 && $this->response !== $model) { + $this->response = $model; + } + } } diff --git a/Command/SwaggerDumpCommand.php b/Command/SwaggerDumpCommand.php new file mode 100644 index 0000000..6b9e93d --- /dev/null +++ b/Command/SwaggerDumpCommand.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Nelmio\ApiDocBundle\Command; + +use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; + + +/** + * Symfony2 command to dump Swagger-compliant JSON files. + * + * @author Bez Hermoso + */ +class SwaggerDumpCommand extends ContainerAwareCommand +{ + protected function configure() + { + $this + ->setDescription('Dump Swagger-compliant JSON files.') + ->addOption('resource', '', InputOption::VALUE_OPTIONAL, 'A specific resource API declaration to dump.') + ->addOption('all', '', InputOption::VALUE_NONE, 'Dump resource list and all API declarations.') + ->addOption('list-only', '', InputOption::VALUE_NONE, 'Dump resource list only.') + ->addArgument('destination', InputOption::VALUE_REQUIRED, 'Directory to dump JSON files in.', null) + ->setName('api:swagger:dump'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $container = $this->getContainer(); + $destination = $input->getArgument('destination'); + + $rootDir = $container->get('kernel')->getRootDir(); + $rootDir = $rootDir . '/..'; + + if (null === $destination) { + $destination = realpath($rootDir . '/' . $destination); + } + + $fs = new Filesystem(); + + if (!$fs->exists($destination)) { + $fs->mkdir($destination); + } + + $destination = realpath($destination); + + if ($input->getOption('all') && $input->getOption('resource')) { + throw new \RuntimeException('Cannot selectively dump a resource with the --all flag.'); + } + + if ($input->getOption('list-only') && $input->getOption('resource')) { + throw new \RuntimeException('Cannot selectively dump a resource with the --list-only flag.'); + } + + if ($input->getOption('all') && $input->getOption('list-only')) { + throw new \RuntimeException('Cannot selectively dump resource list with the --all flag.'); + } + + $output->writeln(''); + $output->writeln('Reading annotations...'); + $extractor = $container->get('nelmio_api_doc.extractor.api_doc_extractor'); + $data = $extractor->all(); + + if ($input->getOption('list-only')) { + $this->dumpResourceList($destination, $data, $output); + } + + if (false != ($resource = $input->getOption('resource'))) { + $this->dumpApiDeclaration($destination, $data, $resource, $output); + } + + if ($input->getOption('all')) { + $formatter = $container->get('nelmio_api_doc.formatter.swagger_formatter'); + $this->dumpResourceList($destination, $data, $output); + $list = $formatter->format($data); + foreach ($list['apis'] as $api) { + $this->dumpApiDeclaration($destination, $data, substr($api['path'], 1), $output); + } + } + } + + protected function dumpResourceList($destination, array $data, OutputInterface $output) + { + $container = $this->getContainer(); + $formatter = $container->get('nelmio_api_doc.formatter.swagger_formatter'); + + $list = $formatter->format($data); + + $fs = new Filesystem(); + $path = $destination . '/api-docs.json'; + + $string = sprintf('Dump resource list to %s: ', $path); + try { + $fs->dumpFile($path, json_encode($list)); + } catch (IOException $e) { + $output->writeln($string . ' NOT OK'); + } + $output->writeln($string . 'OK'); + } + + protected function dumpApiDeclaration($destination, array $data, $resource, OutputInterface $output) + { + $container = $this->getContainer(); + $formatter = $container->get('nelmio_api_doc.formatter.swagger_formatter'); + + $list = $formatter->format($data, '/' . $resource); + + $fs = new Filesystem(); + $path = sprintf($destination . '/%s.json', $resource); + + $string = sprintf('Dump API declaration to %s: ', $path); + try { + $fs->dumpFile($path, json_encode($list)); + } catch (IOException $e) { + $output->writeln($string . ' NOT OK'); + } + $output->writeln($string . 'OK'); + } +} \ No newline at end of file diff --git a/Controller/ApiDocController.php b/Controller/ApiDocController.php index 8ea71aa..33c75f8 100644 --- a/Controller/ApiDocController.php +++ b/Controller/ApiDocController.php @@ -11,7 +11,11 @@ namespace Nelmio\ApiDocBundle\Controller; +use Nelmio\ApiDocBundle\Formatter\RequestAwareSwaggerFormatter; use Symfony\Bundle\FrameworkBundle\Controller\Controller; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class ApiDocController extends Controller @@ -23,4 +27,19 @@ class ApiDocController extends Controller return new Response($htmlContent, 200, array('Content-Type' => 'text/html')); } + + public function swaggerAction(Request $request, $resource = null) + { + + $docs = $this->get('nelmio_api_doc.extractor.api_doc_extractor')->all(); + $formatter = new RequestAwareSwaggerFormatter($request, $this->get('nelmio_api_doc.formatter.swagger_formatter')); + + $spec = $formatter->format($docs, $resource ? '/' . $resource : null); + + if ($resource !== null && count($spec['apis']) === 0) { + throw $this->createNotFoundException(sprintf('Cannot find resource "%s"', $resource)); + } + + return new JsonResponse($spec); + } } diff --git a/DataTypes.php b/DataTypes.php index 1df2190..60a6a4b 100644 --- a/DataTypes.php +++ b/DataTypes.php @@ -43,7 +43,7 @@ class DataTypes /** * Returns true if the supplied `actualType` value is considered a primitive type. Returns false, otherwise. * - * @param string $type + * @param string $type * @return bool */ public static function isPrimitive($type) diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 22a2836..8c0676d 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -133,6 +133,25 @@ class Configuration implements ConfigurationInterface ->end() ->end() ->end() + ->arrayNode('swagger') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('api_base_path')->defaultValue('/api')->end() + ->scalarNode('swagger_version')->defaultValue('1.2')->end() + ->scalarNode('api_version')->defaultValue('0.1')->end() + ->arrayNode('info') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('title')->defaultValue('Symfony2')->end() + ->scalarNode('description')->defaultValue('My awesome Symfony2 app!')->end() + ->scalarNode('TermsOfServiceUrl')->defaultValue(null)->end() + ->scalarNode('contact')->defaultValue(null)->end() + ->scalarNode('license')->defaultValue(null)->end() + ->scalarNode('licenseUrl')->defaultValue(null)->end() + ->end() + ->end() + ->end() + ->end() ->end(); return $treeBuilder; diff --git a/DependencyInjection/NelmioApiDocExtension.php b/DependencyInjection/NelmioApiDocExtension.php index f5b3c78..7734b98 100644 --- a/DependencyInjection/NelmioApiDocExtension.php +++ b/DependencyInjection/NelmioApiDocExtension.php @@ -11,11 +11,11 @@ namespace Nelmio\ApiDocBundle\DependencyInjection; +use Symfony\Component\Config\FileLocator; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\Config\Definition\Processor; -use Symfony\Component\Config\FileLocator; class NelmioApiDocExtension extends Extension { @@ -57,6 +57,12 @@ class NelmioApiDocExtension extends Extension if (!interface_exists('\Symfony\Component\Validator\MetadataFactoryInterface')) { $container->setParameter('nelmio_api_doc.parser.validation_parser.class', 'Nelmio\ApiDocBundle\Parser\ValidationParserLegacy'); } + + $container->setParameter('nelmio_api_doc.swagger.base_path', $config['swagger']['api_base_path']); + $container->setParameter('nelmio_api_doc.swagger.swagger_version', $config['swagger']['swagger_version']); + $container->setParameter('nelmio_api_doc.swagger.api_version', $config['swagger']['api_version']); + $container->setParameter('nelmio_api_doc.swagger.info', $config['swagger']['info']); + } /** diff --git a/DependencyInjection/SwaggerConfigCompilerPass.php b/DependencyInjection/SwaggerConfigCompilerPass.php new file mode 100644 index 0000000..3e9d488 --- /dev/null +++ b/DependencyInjection/SwaggerConfigCompilerPass.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Nelmio\ApiDocBundle\DependencyInjection; + +use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Parameter; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Filesystem\Exception\IOException; +use Symfony\Component\Filesystem\Filesystem; + + +/** + * Compiler pass that configures the SwaggerFormatter instance. + * + * @author Bez Hermoso + */ +class SwaggerConfigCompilerPass implements CompilerPassInterface +{ + + /** + * You can modify the container here before it is dumped to PHP code. + * + * @param ContainerBuilder $container + * + * @api + */ + public function process(ContainerBuilder $container) + { + $formatter = $container->getDefinition('nelmio_api_doc.formatter.swagger_formatter'); + + $formatter->addMethodCall('setBasePath', array($container->getParameter('nelmio_api_doc.swagger.base_path'))); + $formatter->addMethodCall('setApiVersion', array($container->getParameter('nelmio_api_doc.swagger.api_version'))); + $formatter->addMethodCall('setSwaggerVersion', array($container->getParameter('nelmio_api_doc.swagger.swagger_version'))); + $formatter->addMethodCall('setInfo', array($container->getParameter('nelmio_api_doc.swagger.info'))); + + } +} \ No newline at end of file diff --git a/Extractor/ApiDocExtractor.php b/Extractor/ApiDocExtractor.php index eeb4a6a..133a2d5 100644 --- a/Extractor/ApiDocExtractor.php +++ b/Extractor/ApiDocExtractor.php @@ -317,6 +317,45 @@ class ApiDocExtractor $response = $this->generateHumanReadableTypes($response); $annotation->setResponse($response); + $annotation->setResponseForStatusCode($response, $normalizedOutput, 200); + } + + if (count($annotation->getResponseMap()) > 0) { + + foreach ($annotation->getResponseMap() as $code => $modelName) { + + if ('200' === (string) $code && isset($modelName['type']) && isset($modelName['model'])) { + /* + * Model was already parsed as the default `output` for this ApiDoc. + */ + continue; + } + + $normalizedModel = $this->normalizeClassParameter($modelName); + + $parameters = array(); + $supportedParsers = array(); + foreach ($this->getParsers($normalizedModel) as $parser) { + if ($parser->supports($normalizedModel)) { + $supportedParsers[] = $parser; + $parameters = $this->mergeParameters($parameters, $parser->parse($normalizedModel)); + } + } + + foreach ($supportedParsers as $parser) { + if ($parser instanceof PostParserInterface) { + $mp = $parser->postParse($normalizedModel, $parameters); + $parameters = $this->mergeParameters($parameters, $mp); + } + } + + $parameters = $this->clearClasses($parameters); + $parameters = $this->generateHumanReadableTypes($parameters); + + $annotation->setResponseForStatusCode($parameters, $normalizedModel, $code); + + } + } return $annotation; @@ -455,16 +494,20 @@ class ApiDocExtractor /** * Creates a human-readable version of the `actualType`. `subType` is taken into account. * - * @param string $actualType - * @param string $subType + * @param string $actualType + * @param string $subType * @return string */ protected function generateHumanReadableType($actualType, $subType) { if ($actualType == DataTypes::MODEL) { - $parts = explode('\\', $subType); - return sprintf('object (%s)', end($parts)); + if (class_exists($subType)) { + $parts = explode('\\', $subType); + return sprintf('object (%s)', end($parts)); + } + + return sprintf('object (%s)', $subType); } if ($actualType == DataTypes::COLLECTION) { @@ -475,7 +518,6 @@ class ApiDocExtractor if (class_exists($subType)) { $parts = explode('\\', $subType); - return sprintf('array of objects (%s)', end($parts)); } diff --git a/Formatter/RequestAwareSwaggerFormatter.php b/Formatter/RequestAwareSwaggerFormatter.php new file mode 100644 index 0000000..049e9d2 --- /dev/null +++ b/Formatter/RequestAwareSwaggerFormatter.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Nelmio\ApiDocBundle\Formatter; + + +use Nelmio\ApiDocBundle\Annotation\ApiDoc; +use Symfony\Component\HttpFoundation\Request; + +/** + * Extends SwaggerFormatter which takes into account the request's base URL when generating the documents for direct swagger-ui consumption. + * + * @author Bezalel Hermoso + */ +class RequestAwareSwaggerFormatter implements FormatterInterface +{ + /** + * @var \Symfony\Component\HttpFoundation\Request + */ + protected $request; + + /** + * @var SwaggerFormatter + */ + protected $formatter; + + /** + * @param Request $request + * @param SwaggerFormatter $formatter + */ + public function __construct(Request $request, SwaggerFormatter $formatter) + { + $this->request = $request; + $this->formatter = $formatter; + } + + /** + * Format a collection of documentation data. + * + * @param array $collection + * @param null $resource + * @internal param $array [ApiDoc] $collection + * @return string|array + */ + public function format(array $collection, $resource = null) + { + $result = $this->formatter->format($collection, $resource); + + if ($resource !== null) { + $result['basePath'] = $this->request->getBaseUrl() . $result['basePath']; + } + + return $result; + } + + /** + * Format documentation data for one route. + * + * @param ApiDoc $annotation + * return string|array + */ + public function formatOne(ApiDoc $annotation) + { + return $this->formatter->formatOne($annotation); + } +} \ No newline at end of file diff --git a/Formatter/SwaggerFormatter.php b/Formatter/SwaggerFormatter.php new file mode 100644 index 0000000..6b9cb53 --- /dev/null +++ b/Formatter/SwaggerFormatter.php @@ -0,0 +1,609 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Nelmio\ApiDocBundle\Formatter; + + +use Nelmio\ApiDocBundle\Annotation\ApiDoc; +use Nelmio\ApiDocBundle\DataTypes; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Router; +use Symfony\Component\Routing\RouterInterface; + +/** + * Produces Swagger-compliant resource lists and API declarations as defined here: + * https://github.com/wordnik/swagger-spec/blob/master/versions/1.2.md + * + * This formatter produces an array. Therefore output still needs to be `json_encode`d before passing on as HTTP response. + * + * @author Bezalel Hermoso + */ +class SwaggerFormatter implements FormatterInterface +{ + protected $basePath; + + protected $apiVersion; + + protected $swaggerVersion; + + protected $info = array(); + + protected $typeMap = array( + DataTypes::INTEGER => 'integer', + DataTypes::FLOAT => 'number', + DataTypes::STRING => 'string', + DataTypes::BOOLEAN => 'boolean', + DataTypes::FILE => 'string', + DataTypes::DATE => 'string', + DataTypes::DATETIME => 'string', + ); + + protected $formatMap = array( + DataTypes::INTEGER => 'int32', + DataTypes::FLOAT => 'float', + DataTypes::FILE => 'byte', + DataTypes::DATE => 'date', + DataTypes::DATETIME => 'date-time', + ); + + /** + * Format a collection of documentation data. + * + * If resource is provided, an API declaration for that resource is produced. Otherwise, a resource listing is returned. + * + * @param array|ApiDoc[] $collection + * @param null|string $resource + * @return string|array + */ + public function format(array $collection, $resource = null) + { + if ($resource === null) { + return $this->produceResourceListing($collection); + } else { + return $this->produceApiDeclaration($collection, $resource); + } + } + + /** + * Formats the collection into Swagger-compliant output. + * + * @param array $collection + * @return array + */ + public function produceResourceListing(array $collection) + { + $resourceList = array( + 'swaggerVersion' => (string) $this->swaggerVersion, + 'apis' => array(), + 'apiVersion' => (string) $this->apiVersion, + 'info' => $this->getInfo(), + 'authorizations' => $this->getAuthorizations(), + ); + + $apis = &$resourceList['apis']; + + foreach ($collection as $item) { + + /** @var $apiDoc ApiDoc */ + $apiDoc = $item['annotation']; + $resource = $item['resource']; + + if (!$apiDoc->isResource()) { + continue; + } + + $subPath = $this->stripBasePath($resource); + $normalizedName = $this->normalizeResourcePath($subPath); + + $apis[] = array( + 'path' => '/' . $normalizedName, + 'description' => $apiDoc->getResourceDescription(), + ); + + } + + return $resourceList; + } + + protected function getAuthorizations() + { + return array(); + } + + /** + * @return array + */ + protected function getInfo() + { + return $this->info; + } + + /** + * Format documentation data for one route. + * + * @param ApiDoc $annotation + * return string|array + * @throws \BadMethodCallException + */ + public function formatOne(ApiDoc $annotation) + { + throw new \BadMethodCallException(sprintf('%s does not support formatting a single ApiDoc only.', __CLASS__)); + } + + /** + * Formats collection to produce a Swagger-compliant API declaration for the given resource. + * + * @param array $collection + * @param string $resource + * @return array + */ + protected function produceApiDeclaration(array $collection, $resource) + { + + $apiDeclaration = array( + 'swaggerVersion' => (string) $this->swaggerVersion, + 'apiVersion' => (string) $this->apiVersion, + 'basePath' => $this->basePath, + 'resourcePath' => $resource, + 'apis' => array(), + 'models' => array(), + 'produces' => array(), + 'consumes' => array(), + 'authorizations' => array(), + ); + + $main = null; + + $apiBag = array(); + + $models = array(); + + + foreach ($collection as $item) { + + /** @var $apiDoc ApiDoc */ + $apiDoc = $item['annotation']; + $itemResource = $this->stripBasePath($item['resource']); + + $route = $apiDoc->getRoute(); + + $itemResource = $this->normalizeResourcePath($itemResource); + + if ('/' . $itemResource !== $resource) { + continue; + } + + $compiled = $route->compile(); + + $path = $this->stripBasePath($route->getPath()); + + if (!isset($apiBag[$path])) { + $apiBag[$path] = array(); + } + + $parameters = array(); + $responseMessages = array(); + + foreach ($compiled->getPathVariables() as $paramValue) { + $parameter = array( + 'paramType' => 'path', + 'name' => $paramValue, + 'type' => 'string', + 'required' => true, + ); + + if ($paramValue === '_format' && false != ($req = $route->getRequirement('_format'))) { + $parameter['enum'] = explode('|', $req); + } + + $parameters[] = $parameter; + } + + if (isset($data['filters'])) { + $parameters = array_merge($parameters, $this->deriveQueryParameters($data['filters'])); + } + + $data = $apiDoc->toArray(); + + if (isset($data['parameters'])) { + $parameters = array_merge($parameters, $this->deriveParameters($data['parameters'], $models)); + } + + $responseMap = $apiDoc->getParsedResponseMap(); + + $statusMessages = isset($data['statusCodes']) ? $data['statusCodes'] : array(); + + foreach ($responseMap as $statusCode => $prop) { + + if (isset($statusMessages[$statusCode])) { + $message = is_array($statusMessages[$statusCode]) ? implode('; ', $statusMessages[$statusCode]) : $statusCode[$statusCode]; + } else { + $message = sprintf('See standard HTTP status code reason for %s', $statusCode); + } + + $responseModel = array( + 'code' => $statusCode, + 'message' => $message, + 'responseModel' => $this->registerModel($prop['type']['class'], $prop['model'], '', $models), + ); + $responseMessages[$statusCode] = $responseModel; + } + + $unmappedMessages = array_diff(array_keys($statusMessages), array_keys($responseMessages)); + + foreach ($unmappedMessages as $code) { + $responseMessages[$code] = array( + 'code' => $code, + 'message' => is_array($statusMessages[$code]) ? implode('; ', $statusMessages[$code]) : $statusMessages[$code], + ); + } + + $type = isset($responseMessages[200]['responseModel']) ? $responseMessages[200]['responseModel'] : null; + + foreach ($apiDoc->getRoute()->getMethods() as $method) { + $operation = array( + 'method' => $method, + 'summary' => $apiDoc->getDescription(), + 'nickname' => $this->generateNickname($method, $itemResource), + 'parameters' => $parameters, + 'responseMessages' => array_values($responseMessages), + ); + + if ($type !== null) { + $operation['type'] = $type; + } + + $apiBag[$path][] = $operation; + } + } + + $apiDeclaration['resourcePath'] = $resource; + + foreach ($apiBag as $path => $operations) { + $apiDeclaration['apis'][] = array( + 'path' => $path, + 'operations' => $operations, + ); + } + + $apiDeclaration['models'] = $models; + + return $apiDeclaration; + } + + /** + * Slugify a URL path. Trims out path parameters wrapped in curly brackets. + * + * @param $path + * @return string + */ + protected function normalizeResourcePath($path) + { + $path = preg_replace('/({.*?})/', '', $path); + $path = trim(preg_replace('/[^0-9a-zA-Z]/', '-', $path), '-'); + $path = preg_replace('/-+/', '-', $path); + return $path; + } + + /** + * @param $path + */ + public function setBasePath($path) + { + $this->basePath = $path; + } + + /** + * Formats query parameters to Swagger-compliant form. + * + * @param array $input + * @return array + */ + protected function deriveQueryParameters(array $input) + { + $parameters = array(); + + foreach ($input as $name => $prop) { + $parameters[] = array( + 'paramType' => 'query', + 'name' => $name, + 'type' => isset($this->typeMap[$prop['dataType']]) ? $this->typeMap[$prop['dataType']] : 'string', + ); + } + + return $parameters; + + } + + /** + * Builds a Swagger-compliant parameter list from the provided parameter array. Models are built when necessary. + * + * @param array $input + * @param array $models + * @return array + */ + protected function deriveParameters(array $input, array &$models) + { + + $parameters = array(); + + foreach ($input as $name => $prop) { + + $type = null; + $format = null; + $ref = null; + $enum = null; + $items = null; + + if (isset ($this->typeMap[$prop['actualType']])) { + $type = $this->typeMap[$prop['actualType']]; + } else { + switch ($prop['actualType']) { + case DataTypes::ENUM: + $type = 'string'; + if (isset($prop['format'])) { + $enum = array_keys(json_decode($prop['format'], true)); + } + break; + + case DataTypes::MODEL: + $ref = + $this->registerModel( + $prop['subType'], + isset($prop['children']) ? $prop['children'] : null, + $prop['description'] ?: $prop['dataType'], + $models + ); + break; + } + } + + if (isset($this->formatMap[$prop['actualType']])) { + $format = $this->formatMap[$prop['actualType']]; + } + + if (null === $type && null === $ref) { + /* `type` or `$ref` is required. Continue to next of none of these was determined. */ + continue; + } + + $parameter = array( + 'paramType' => 'form', + 'name' => $name, + ); + + if (null !== $type) { + $parameter['type'] = $type; + } + + if (null !== $ref) { + $parameter['$ref'] = $ref; + $parameter['type'] = $ref; + } + + if (null !== $format) { + $parameter['format'] = $format; + } + + if (is_array($enum) && count($enum) > 0) { + $parameter['enum'] = $enum; + } + + if ($prop['default'] !== null) { + $parameter['defaultValue'] = $prop['default']; + } + + $parameters[] = $parameter; + } + + return $parameters; + } + + /** + * Registers a model into the model array. Returns a unique identifier for the model to be used in `$ref` properties. + * + * @param $className + * @param array $parameters + * @param string $description + * @param $models + * @return mixed + */ + public function registerModel($className, array $parameters = null, $description = '', &$models) + { + if (isset ($models[$className])) { + return $models[$className]['id']; + } + + /* + * Converts \Fully\Qualified\Class\Name to Fully.Qualified.Class.Name + */ + $id = preg_replace('#(\\\|[^A-Za-z0-9])#', '.', $className); + //Replace duplicate dots. + $id = preg_replace('/\.+/', '.', $id); + //Replace trailing dots. + $id = preg_replace('/^\./', '', $id); + + $model = array( + 'id' => $id, + 'description' => $description, + ); + + if (is_array($parameters)) { + + $required = array(); + $properties = array(); + + foreach ($parameters as $name => $prop) { + + $subParam = array(); + + if ($prop['actualType'] === DataTypes::MODEL) { + + $subParam['$ref'] = $this->registerModel( + $prop['subType'], + isset($prop['children']) ? $prop['children'] : null, + $prop['description'] ?: $prop['dataType'], + $models + ); + + } else { + + $type = null; + $format = null; + $items = null; + $enum = null; + $ref = null; + + if (isset($this->typeMap[$prop['actualType']])) { + $type = $this->typeMap[$prop['actualType']]; + } else{ + + switch ($prop['actualType']) { + case DataTypes::ENUM: + $type = 'string'; + if (isset($prop['format'])) { + $enum = array_keys(json_decode($prop['format'], true)); + } + break; + + case DataTypes::COLLECTION: + $type = 'array'; + + if ($prop['subType'] === DataTypes::MODEL) { + + } else { + + if ($prop['subType'] === null + || isset($this->typeMap[$prop['subType']])) { + $items = array( + 'type' => 'string', + ); + } elseif (!isset($this->typeMap[$prop['subType']])) { + $items = array( + '$ref' => + $this->registerModel( + $prop['subType'], + isset($prop['children']) ? $prop['children'] : null, + $prop['description'] ?: $prop['dataType'], + $models + ) + ); + } + } + /* @TODO: Handle recursion if subtype is a model. */ + break; + + case DataTypes::MODEL: + $ref = $this->registerModel( + $prop['subType'], + isset($prop['children']) ? $prop['children'] : null, + $prop['description'] ?: $prop['dataType'], + $models + ); + $type = $ref; + /* @TODO: Handle recursion. */ + break; + } + } + + if (isset($this->formatMap[$prop['actualType']])) { + $format = $this->formatMap[$prop['actualType']]; + } + + $subParam = array( + 'type' => $type, + 'description' => empty($prop['description']) === false ? (string) $prop['description'] : $prop['dataType'], + ); + + if ($format !== null) { + $subParam['format'] = $format; + } + + if ($enum !== null) { + $subParam['enum'] = $enum; + } + + if ($ref !== null) { + $subParam['$ref'] = $ref; + } + + if ($items !== null) { + $subParam['items'] = $items; + } + + if ($prop['required']) { + $required[] = $name; + } + + } + + $properties[$name] = $subParam; + } + + $model['properties'] = $properties; + $model['required'] = $required; + $models[$id] = $model; + } + + return $id; + } + + /** + * @param mixed $swaggerVersion + */ + public function setSwaggerVersion($swaggerVersion) + { + $this->swaggerVersion = $swaggerVersion; + } + + /** + * @param mixed $apiVersion + */ + public function setApiVersion($apiVersion) + { + $this->apiVersion = $apiVersion; + } + + /** + * @param mixed $info + */ + public function setInfo($info) + { + $this->info = $info; + } + + /** + * Strips the base path from a URL path. + * + * @param $path + * @return mixed + */ + protected function stripBasePath($path) + { + $pattern = sprintf('#%s#', preg_quote($this->basePath)); + $subPath = preg_replace($pattern, '', $path); + return $subPath; + } + + /** + * Generate nicknames based on support HTTP methods and the resource name. + * + * @param $method + * @param $resource + * @return string + */ + protected function generateNickname($method, $resource) + { + $resource = preg_replace('#/^#', '', $resource); + $resource = $this->normalizeResourcePath($resource); + return sprintf('%s_%s', strtolower($method), $resource); + } +} \ No newline at end of file diff --git a/NelmioApiDocBundle.php b/NelmioApiDocBundle.php index a1851e7..7f26e16 100644 --- a/NelmioApiDocBundle.php +++ b/NelmioApiDocBundle.php @@ -2,6 +2,7 @@ namespace Nelmio\ApiDocBundle; +use Nelmio\ApiDocBundle\DependencyInjection\SwaggerConfigCompilerPass; use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\DependencyInjection\ContainerBuilder; use Nelmio\ApiDocBundle\DependencyInjection\LoadExtractorParsersPass; @@ -17,5 +18,6 @@ class NelmioApiDocBundle extends Bundle $container->addCompilerPass(new LoadExtractorParsersPass()); $container->addCompilerPass(new RegisterExtractorParsersPass()); $container->addCompilerPass(new ExtractorHandlerCompilerPass()); + $container->addCompilerPass(new SwaggerConfigCompilerPass()); } } diff --git a/Resources/config/formatters.xml b/Resources/config/formatters.xml index 5cd711f..93b9dd1 100644 --- a/Resources/config/formatters.xml +++ b/Resources/config/formatters.xml @@ -8,6 +8,7 @@ Nelmio\ApiDocBundle\Formatter\MarkdownFormatter Nelmio\ApiDocBundle\Formatter\SimpleFormatter Nelmio\ApiDocBundle\Formatter\HtmlFormatter + Nelmio\ApiDocBundle\Formatter\SwaggerFormatter null @@ -56,6 +57,8 @@ %nelmio_api_doc.sandbox.authentication% + diff --git a/Resources/config/routing.yml b/Resources/config/routing.yml index 4591631..d0bd03f 100644 --- a/Resources/config/routing.yml +++ b/Resources/config/routing.yml @@ -2,4 +2,4 @@ nelmio_api_doc_index: pattern: / defaults: { _controller: NelmioApiDocBundle:ApiDoc:index } requirements: - _method: GET + _method: GET \ No newline at end of file diff --git a/Resources/config/swagger_routing.yml b/Resources/config/swagger_routing.yml new file mode 100644 index 0000000..c36f6d3 --- /dev/null +++ b/Resources/config/swagger_routing.yml @@ -0,0 +1,11 @@ +nelmio_api_doc_swagger_resource_list: + pattern: / + defaults: { _controller: NelmioApiDocBundle:ApiDoc:swagger } + requirements: + _method: GET + +nelmio_api_doc_swagger_api_declaration: + pattern: /{resource} + defaults: { _controller: NelmioApiDocBundle:ApiDoc:swagger } + requirements: + _method: GET \ No newline at end of file diff --git a/Resources/doc/swagger-support.md b/Resources/doc/swagger-support.md new file mode 100644 index 0000000..b2b50fe --- /dev/null +++ b/Resources/doc/swagger-support.md @@ -0,0 +1,116 @@ +NelmioApiDocBundle +=================== + +##Swagger support## + +It is now possible to make your application produce Swagger-compliant JSON output based on `@ApiDoc` annotations, which can be used for consumption by [swagger-ui](https://github.com/wordnik/swagger-ui). + +###Annotations### + +A couple of properties has been added to `@ApiDoc`: + +To define a __resource description__: + +```php +assertTrue(is_array($array['tags']), 'Tags should be in array'); $this->assertEquals($data['tags'], $array['tags']); } + + public function testAlignmentOfOutputAndResponseModels() + { + $data = array( + 'output' => 'FooBar', + 'responseMap' => array( + 400 => 'Foo\\ValidationErrorCollection', + ), + ); + + $apiDoc = new ApiDoc($data); + + $map = $apiDoc->getResponseMap(); + + $this->assertCount(2, $map); + $this->assertArrayHasKey(200, $map); + $this->assertArrayHasKey(400, $map); + $this->assertEquals($data['output'], $map[200]); + } + + public function testAlignmentOfOutputAndResponseModels2() + { + $data = array( + 'responseMap' => array( + 200 => 'FooBar', + 400 => 'Foo\\ValidationErrorCollection', + ), + ); + + $apiDoc = new ApiDoc($data); + $map = $apiDoc->getResponseMap(); + + $this->assertCount(2, $map); + $this->assertArrayHasKey(200, $map); + $this->assertArrayHasKey(400, $map); + $this->assertEquals($apiDoc->getOutput(), $map[200]); + } } diff --git a/Tests/Controller/ApiDocControllerTest.php b/Tests/Controller/ApiDocControllerTest.php new file mode 100644 index 0000000..e3c955f --- /dev/null +++ b/Tests/Controller/ApiDocControllerTest.php @@ -0,0 +1,66 @@ + + */ +class ApiDocControllerTest extends WebTestCase +{ + public function testSwaggerDocResourceListRoute() + { + $client = static::createClient(); + $client->request('GET', '/api-docs/'); + + $response = $client->getResponse(); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('application/json', $response->headers->get('Content-type')); + + } + + public function dataTestApiDeclarations() + { + return array( + array('resources'), + array('tests'), + array('tests2'), + array('TestResource'), + ); + } + + /** + * @dataProvider dataTestApiDeclarations + */ + public function testApiDeclarationRoutes($resource) + { + $client = static::createClient(); + $client->request('GET', '/api-docs/' . $resource); + + $response = $client->getResponse(); + + $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('application/json', $response->headers->get('Content-type')); + } + + public function testNonExistingApiDeclaration() + { + $client = static::createClient(); + $client->request('GET', '/api-docs/santa'); + + $response = $client->getResponse(); + $this->assertEquals(404, $response->getStatusCode()); + + } +} diff --git a/Tests/Extractor/ApiDocExtractorTest.php b/Tests/Extractor/ApiDocExtractorTest.php index 7207c14..abd969f 100644 --- a/Tests/Extractor/ApiDocExtractorTest.php +++ b/Tests/Extractor/ApiDocExtractorTest.php @@ -15,7 +15,7 @@ use Nelmio\ApiDocBundle\Tests\WebTestCase; class ApiDocExtractorTest extends WebTestCase { - const ROUTES_QUANTITY = 25; + const ROUTES_QUANTITY = 31; public function testAll() { @@ -38,39 +38,39 @@ class ApiDocExtractorTest extends WebTestCase $this->assertNotNull($d['resource']); } - $a1 = $data[0]['annotation']; + $a1 = $data[7]['annotation']; $array1 = $a1->toArray(); $this->assertTrue($a1->isResource()); $this->assertEquals('index action', $a1->getDescription()); $this->assertTrue(is_array($array1['filters'])); $this->assertNull($a1->getInput()); - $a1 = $data[1]['annotation']; + $a1 = $data[7]['annotation']; $array1 = $a1->toArray(); $this->assertTrue($a1->isResource()); $this->assertEquals('index action', $a1->getDescription()); $this->assertTrue(is_array($array1['filters'])); $this->assertNull($a1->getInput()); - $a2 = $data[2]['annotation']; + $a2 = $data[8]['annotation']; $array2 = $a2->toArray(); $this->assertFalse($a2->isResource()); $this->assertEquals('create test', $a2->getDescription()); $this->assertFalse(isset($array2['filters'])); $this->assertEquals('Nelmio\ApiDocBundle\Tests\Fixtures\Form\TestType', $a2->getInput()); - $a2 = $data[3]['annotation']; + $a2 = $data[9]['annotation']; $array2 = $a2->toArray(); $this->assertFalse($a2->isResource()); $this->assertEquals('create test', $a2->getDescription()); $this->assertFalse(isset($array2['filters'])); $this->assertEquals('Nelmio\ApiDocBundle\Tests\Fixtures\Form\TestType', $a2->getInput()); - $a4 = $data[5]['annotation']; + $a4 = $data[11]['annotation']; $this->assertTrue($a4->isResource()); $this->assertEquals('TestResource', $a4->getResource()); - $a3 = $data['14']['annotation']; + $a3 = $data[20]['annotation']; $this->assertTrue($a3->getHttps()); } @@ -224,6 +224,7 @@ class ApiDocExtractorTest extends WebTestCase $this->assertNotNull($annotation); $output = $annotation->getOutput(); + $parsers = $output['parsers']; $this->assertEquals( "Nelmio\\ApiDocBundle\\Parser\\JmsMetadataParser", diff --git a/Tests/Fixtures/Controller/ResourceController.php b/Tests/Fixtures/Controller/ResourceController.php new file mode 100644 index 0000000..502f9c2 --- /dev/null +++ b/Tests/Fixtures/Controller/ResourceController.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Nelmio\ApiDocBundle\Tests\Fixtures\Controller; +use Nelmio\ApiDocBundle\Annotation\ApiDoc; + +class ResourceController +{ + /** + * @ApiDoc( + * resource=true, + * resourceDescription="Operations on resource.", + * description="List resources.", + * statusCodes={200 = "Returned on success.", 404 = "Returned if resource cannot be found."} + * ) + */ + public function listResourcesAction() + { + + } + + /** + * @ApiDoc(description="Retrieve a resource by ID.") + */ + public function getResourceAction() + { + + } + + /** + * @ApiDoc(description="Delete a resource by ID.") + */ + public function deleteResourceAction() + { + + } + + /** + * @ApiDoc( + * description="Create a new resource.", + * input={"class" = "Nelmio\ApiDocBundle\Tests\Fixtures\Form\SimpleType", "name" = ""}, + * output="Nelmio\ApiDocBundle\Tests\Fixtures\Model\JmsNested" + * ) + */ + public function createResourceAction() + { + + } + + /** + * @ApiDoc(resource=true, description="List another resource.", resourceDescription="Operations on another resource.") + */ + public function listAnotherResourcesAction() + { + + } + + /** + * @ApiDoc(description="Retrieve another resource by ID.") + */ + public function getAnotherResourceAction() + { + + } + + /** + * @ApiDoc(description="Update a resource bu ID.") + */ + public function updateAnotherResourceAction() + { + + } +} \ No newline at end of file diff --git a/Tests/Fixtures/Form/SimpleType.php b/Tests/Fixtures/Form/SimpleType.php index e79e176..bb7886a 100644 --- a/Tests/Fixtures/Form/SimpleType.php +++ b/Tests/Fixtures/Form/SimpleType.php @@ -1,6 +1,7 @@ get('nelmio_api_doc.formatter.markdown_formatter')->format($data); $expected = <<=0.2 + +circular[until]: + + * type: string + * versions: <=0.3 + +circular[since_and_until]: + + * type: string + * versions: >=0.4,<=0.5 + +parent: + + * type: object (JmsTest) + +parent[foo]: + + * type: string + +parent[bar]: + + * type: DateTime + +parent[number]: + + * type: double + +parent[arr]: + + * type: array + +parent[nested]: + + * type: object (JmsNested) + +parent[nested_array][]: + + * type: array of objects (JmsNested) + +since: + + * type: string + * versions: >=0.2 + +until: + + * type: string + * versions: <=0.3 + +since_and_until: + + * type: string + * versions: >=0.4,<=0.5 + + +### `GET` /api/resources/{id}.{_format} ### + +_Retrieve a resource by ID._ + +#### Requirements #### + +**_format** + + - Requirement: json|xml|html +**id** + + + +### `DELETE` /api/resources/{id}.{_format} ### + +_Delete a resource by ID._ + +#### Requirements #### + +**_format** + + - Requirement: json|xml|html +**id** + + + ## /tests ## ### `GET` /tests.{_format} ### @@ -431,7 +666,7 @@ nested_array[]: **id** - - Requirement: \\d+ + - Requirement: \d+ ### `GET` /z-action-with-deprecated-indicator ### @@ -459,7 +694,7 @@ param1: page: - * Requirement: \\d+ + * Requirement: \d+ * Description: Page of the overview. * Default: 1 diff --git a/Tests/Formatter/SimpleFormatterTest.php b/Tests/Formatter/SimpleFormatterTest.php index 6c85ff7..31abd0b 100644 --- a/Tests/Formatter/SimpleFormatterTest.php +++ b/Tests/Formatter/SimpleFormatterTest.php @@ -198,7 +198,6 @@ class SimpleFormatterTest extends WebTestCase 'dataType' => 'string', 'actualType' => DataTypes::STRING, 'subType' => null, - 'default' => null, 'default' => "DefaultTest", 'required' => true, 'description' => '', @@ -622,8 +621,6 @@ And, it supports multilines until the first \'@\' char.', ), ), 'https' => false, - 'description' => 'This method is useful to test if the getDocComment works.', - 'documentation' => "This method is useful to test if the getDocComment works.\nAnd, it supports multilines until the first '@' char.", 'authentication' => false, 'authenticationRoles' => array(), 'deprecated' => false, @@ -1069,39 +1066,39 @@ With multiple lines.', 'subType' => null, 'default' => null, 'required' => null, - 'readonly' => null + 'readonly' => null, ) ) ), 'related' => array( 'dataType' => 'object (Test)', + 'actualType' => DataTypes::MODEL, + 'subType' => 'Nelmio\ApiDocBundle\Tests\Fixtures\Model\Test', + 'default' => null, 'readonly' => false, 'required' => false, 'description' => '', 'sinceVersion' => null, 'untilVersion' => null, - 'actualType' => DataTypes::MODEL, - 'subType' => 'Nelmio\ApiDocBundle\Tests\Fixtures\Model\Test', - 'default' => null, 'children' => array( 'a' => array( 'dataType' => 'string', - 'format' => '{length: min: foo}, {not blank}', - 'required' => true, - 'readonly' => null, 'actualType' => DataTypes::STRING, 'subType' => null, 'default' => 'nelmio', + 'format' => '{length: min: foo}, {not blank}', + 'required' => true, + 'readonly' => null ), 'b' => array( 'dataType' => 'DateTime', - 'required' => null, - 'readonly' => null, 'actualType' => DataTypes::DATETIME, 'subType' => null, 'default' => null, + 'required' => null, + 'readonly' => null ) - ) + ), ) ), 'authenticationRoles' => array(), @@ -1147,7 +1144,6 @@ With multiple lines.', 'dataType' => 'string', 'actualType' => DataTypes::STRING, 'subType' => null, - 'default' => null, 'default' => "DefaultTest", 'required' => true, 'description' => '', @@ -1211,33 +1207,33 @@ With multiple lines.', ), 'related' => array( 'dataType' => 'object (Test)', + 'subType' => 'Nelmio\ApiDocBundle\Tests\Fixtures\Model\Test', + 'actualType' => DataTypes::MODEL, + 'default' => null, 'readonly' => false, 'required' => false, 'description' => '', 'sinceVersion' => null, 'untilVersion' => null, - 'actualType' => DataTypes::MODEL, - 'subType' => 'Nelmio\ApiDocBundle\Tests\Fixtures\Model\Test', - 'default' => null, 'children' => array( 'a' => array( 'dataType' => 'string', - 'format' => '{length: min: foo}, {not blank}', - 'required' => true, - 'readonly' => null, 'actualType' => DataTypes::STRING, 'subType' => null, 'default' => 'nelmio', + 'format' => '{length: min: foo}, {not blank}', + 'required' => true, + 'readonly' => null ), 'b' => array( 'dataType' => 'DateTime', - 'required' => null, - 'readonly' => null, 'actualType' => DataTypes::DATETIME, 'subType' => null, 'default' => null, + 'required' => null, + 'readonly' => null ) - ) + ), ) ), 'authenticationRoles' => array(), @@ -1245,7 +1241,6 @@ With multiple lines.', ), '/tests2' => array( - 0 => array( 'method' => 'POST', 'uri' => '/tests2.{_format}', @@ -1267,7 +1262,6 @@ With multiple lines.', ), '/tests2' => array( - 0 => array( 'method' => 'POST', 'uri' => '/tests2.{_format}', @@ -1299,6 +1293,573 @@ With multiple lines.', 'deprecated' => false, ), ), + '/api/other-resources' => + array( + array( + 'method' => 'GET', + 'uri' => '/api/other-resources.{_format}', + 'description' => 'List another resource.', + 'requirements' => + array( + '_format' => + array( + 'requirement' => 'json|xml|html', + 'dataType' => '', + 'description' => '', + ), + ), + 'resourceDescription' => 'Operations on another resource.', + 'https' => false, + 'authentication' => false, + 'authenticationRoles' => + array(), + 'deprecated' => false, + ), + array( + 'method' => 'PUT|PATCH', + 'uri' => '/api/other-resources/{id}.{_format}', + 'description' => 'Update a resource bu ID.', + 'requirements' => + array( + '_format' => + array( + 'requirement' => 'json|xml|html', + 'dataType' => '', + 'description' => '', + ), + 'id' => + array( + 'requirement' => '', + 'dataType' => '', + 'description' => '', + ), + ), + 'https' => false, + 'authentication' => false, + 'authenticationRoles' => + array(), + 'deprecated' => false, + ), + ), + '/api/resources' => + array( + array( + 'method' => 'GET', + 'uri' => '/api/resources.{_format}', + 'description' => 'List resources.', + 'requirements' => + array( + '_format' => + array( + 'requirement' => 'json|xml|html', + 'dataType' => '', + 'description' => '', + ), + ), + 'statusCodes' => + array( + 200 => + array( + 'Returned on success.', + ), + 404 => + array( + 'Returned if resource cannot be found.', + ), + ), + 'resourceDescription' => 'Operations on resource.', + 'https' => false, + 'authentication' => false, + 'authenticationRoles' => + array(), + 'deprecated' => false, + ), + array( + 'method' => 'POST', + 'uri' => '/api/resources.{_format}', + 'description' => 'Create a new resource.', + 'parameters' => + array( + 'a' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'required' => true, + 'description' => 'Something that describes A.', + 'readonly' => false, + 'default' => null, + ), + 'b' => + array( + 'dataType' => 'float', + 'actualType' => 'float', + 'subType' => NULL, + 'required' => true, + 'description' => '', + 'readonly' => false, + 'default' => null, + ), + 'c' => + array( + 'dataType' => 'choice', + 'actualType' => 'choice', + 'subType' => NULL, + 'required' => true, + 'description' => '', + 'readonly' => false, + 'format' => '{"x":"X","y":"Y","z":"Z"}', + 'default' => null, + ), + 'd' => + array( + 'dataType' => 'datetime', + 'actualType' => 'datetime', + 'subType' => NULL, + 'required' => true, + 'description' => '', + 'readonly' => false, + 'default' => null, + ), + 'e' => + array( + 'dataType' => 'date', + 'actualType' => 'date', + 'subType' => NULL, + 'required' => true, + 'description' => '', + 'readonly' => false, + 'default' => null, + ), + 'g' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'required' => true, + 'description' => '', + 'readonly' => false, + 'default' => null, + ), + ), + 'requirements' => + array( + '_format' => + array( + 'requirement' => 'json|xml|html', + 'dataType' => '', + 'description' => '', + ), + ), + 'response' => + array( + 'foo' => + array( + 'dataType' => 'DateTime', + 'actualType' => 'datetime', + 'default' => null, + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => true, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + ), + 'bar' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'default' => 'baz', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + ), + 'baz' => + array( + 'dataType' => 'array of integers', + 'actualType' => 'collection', + 'subType' => 'integer', + 'required' => false, + 'description' => 'Epic description. + +With multiple lines.', + 'readonly' => false, + 'sinceVersion' => NULL, + 'default' => null, + 'untilVersion' => NULL, + ), + 'circular' => + array( + 'dataType' => 'object (JmsNested)', + 'actualType' => 'model', + 'default' => null, + 'subType' => 'Nelmio\\ApiDocBundle\\Tests\\Fixtures\\Model\\JmsNested', + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'children' => + array( + 'foo' => + array( + 'dataType' => 'DateTime', + 'actualType' => 'datetime', + 'default' => null, + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => true, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + ), + 'bar' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'default' => 'baz', + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + ), + 'baz' => + array( + 'dataType' => 'array of integers', + 'actualType' => 'collection', + 'subType' => 'integer', + 'required' => false, + 'description' => 'Epic description. + +With multiple lines.', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + 'circular' => + array( + 'dataType' => 'object (JmsNested)', + 'actualType' => 'model', + 'default' => null, + 'subType' => 'Nelmio\\ApiDocBundle\\Tests\\Fixtures\\Model\\JmsNested', + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + ), + 'parent' => + array( + 'dataType' => 'object (JmsTest)', + 'actualType' => 'model', + 'default' => null, + 'subType' => 'Nelmio\\ApiDocBundle\\Tests\\Fixtures\\Model\\JmsTest', + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'children' => + array( + 'foo' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + 'bar' => + array( + 'dataType' => 'DateTime', + 'actualType' => 'datetime', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => true, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + 'number' => + array( + 'dataType' => 'double', + 'actualType' => 'float', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + 'arr' => + array( + 'dataType' => 'array', + 'actualType' => 'collection', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + 'nested' => + array( + 'dataType' => 'object (JmsNested)', + 'actualType' => 'model', + 'default' => null, + 'subType' => 'Nelmio\\ApiDocBundle\\Tests\\Fixtures\\Model\\JmsNested', + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + ), + 'nested_array' => + array( + 'dataType' => 'array of objects (JmsNested)', + 'actualType' => 'collection', + 'subType' => 'Nelmio\\ApiDocBundle\\Tests\\Fixtures\\Model\\JmsNested', + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + ), + ), + 'since' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => '0.2', + 'untilVersion' => NULL, + 'default' => null, + ), + 'until' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => '0.3', + 'default' => null, + ), + 'since_and_until' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => '0.4', + 'untilVersion' => '0.5', + 'default' => null, + ), + ), + ), + 'parent' => + array( + 'dataType' => 'object (JmsTest)', + 'actualType' => 'model', + 'default' => null, + 'subType' => 'Nelmio\\ApiDocBundle\\Tests\\Fixtures\\Model\\JmsTest', + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'children' => + array( + 'foo' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'default' => null, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + ), + 'bar' => + array( + 'dataType' => 'DateTime', + 'actualType' => 'datetime', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => true, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + 'number' => + array( + 'dataType' => 'double', + 'actualType' => 'float', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + 'arr' => + array( + 'dataType' => 'array', + 'actualType' => 'collection', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + 'nested' => + array( + 'dataType' => 'object (JmsNested)', + 'actualType' => 'model', + 'default' => null, + 'subType' => 'Nelmio\\ApiDocBundle\\Tests\\Fixtures\\Model\\JmsNested', + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + ), + 'nested_array' => + array( + 'dataType' => 'array of objects (JmsNested)', + 'actualType' => 'collection', + 'subType' => 'Nelmio\\ApiDocBundle\\Tests\\Fixtures\\Model\\JmsNested', + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => NULL, + 'default' => null, + ), + ), + ), + 'since' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => '0.2', + 'untilVersion' => NULL, + 'default' => null, + ), + 'until' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => NULL, + 'untilVersion' => '0.3', + 'default' => null, + ), + 'since_and_until' => + array( + 'dataType' => 'string', + 'actualType' => 'string', + 'subType' => NULL, + 'required' => false, + 'description' => '', + 'readonly' => false, + 'sinceVersion' => '0.4', + 'untilVersion' => '0.5', + 'default' => null, + ), + ), + 'https' => false, + 'authentication' => false, + 'authenticationRoles' => + array(), + 'deprecated' => false, + ), + array( + 'method' => 'GET', + 'uri' => '/api/resources/{id}.{_format}', + 'description' => 'Retrieve a resource by ID.', + 'requirements' => + array( + '_format' => + array( + 'requirement' => 'json|xml|html', + 'dataType' => '', + 'description' => '', + ), + 'id' => + array( + 'requirement' => '', + 'dataType' => '', + 'description' => '', + ), + ), + 'https' => false, + 'authentication' => false, + 'authenticationRoles' => + array(), + 'deprecated' => false, + ), + array( + 'method' => 'DELETE', + 'uri' => '/api/resources/{id}.{_format}', + 'description' => 'Delete a resource by ID.', + 'requirements' => + array( + '_format' => + array( + 'requirement' => 'json|xml|html', + 'dataType' => '', + 'description' => '', + ), + 'id' => + array( + 'requirement' => '', + 'dataType' => '', + 'description' => '', + ), + ), + 'https' => false, + 'authentication' => false, + 'authenticationRoles' => + array(), + 'deprecated' => false, + ), + ), ); $this->assertEquals($expected, $result); diff --git a/Tests/Formatter/SwaggerFormatterTest.php b/Tests/Formatter/SwaggerFormatterTest.php new file mode 100644 index 0000000..488849b --- /dev/null +++ b/Tests/Formatter/SwaggerFormatterTest.php @@ -0,0 +1,730 @@ + + */ +class SwaggerFormatterTest extends WebTestCase +{ + /** + * @var ApiDocExtractor + */ + protected $extractor; + + /** + * @var SwaggerFormatter + */ + protected $formatter; + + protected function setUp() + { + parent::setUp(); + + $container = $this->getContainer(); + $this->extractor = $container->get('nelmio_api_doc.extractor.api_doc_extractor'); + $this->formatter = $container->get('nelmio_api_doc.formatter.swagger_formatter'); + } + + + public function testResourceListing() + { + + set_error_handler(array($this, 'handleDeprecation')); + $data = $this->extractor->all(); + restore_error_handler(); + + /** @var $formatter SwaggerFormatter */ + + $actual = $this->formatter->format($data, null); + + + $expected = array( + 'swaggerVersion' => '1.2', + 'apiVersion' => '3.14', + 'info' => + array( + 'title' => 'Nelmio Swagger', + 'description' => 'Testing Swagger integration.', + 'TermsOfServiceUrl' => 'https://github.com', + 'contact' => 'user@domain.tld', + 'license' => 'MIT', + 'licenseUrl' => 'http://opensource.org/licenses/MIT', + ), + 'authorizations' => + array(), + 'apis' => + array( + array( + 'path' => '/other-resources', + 'description' => 'Operations on another resource.', + ), + array( + 'path' => '/resources', + 'description' => 'Operations on resource.', + ), + array( + 'path' => '/tests', + 'description' => NULL, + ), + array( + 'path' => '/tests', + 'description' => NULL, + ), + array( + 'path' => '/tests2', + 'description' => NULL, + ), + array( + 'path' => '/TestResource', + 'description' => NULL, + ), + ), + ); + + $this->assertEquals($expected, $actual); + + + } + + /** + * @dataProvider dataTestApiDeclaration + */ + public function testApiDeclaration($resource, $expected) + { + set_error_handler(array($this, 'handleDeprecation')); + $data = $this->extractor->all(); + restore_error_handler(); + + $actual = $this->formatter->format($data, $resource); + + $this->assertEquals($expected, $actual); + + } + + public function dataTestApiDeclaration() + { + return array( + array( + '/resources', + array( + 'swaggerVersion' => '1.2', + 'apiVersion' => '3.14', + 'basePath' => '/api', + 'resourcePath' => '/resources', + 'apis' => + array( + + array( + 'path' => '/resources.{_format}', + 'operations' => + array( + array( + 'method' => 'GET', + 'summary' => 'List resources.', + 'nickname' => 'get_resources', + 'parameters' => + array( + + array( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + 'enum' => + array( + 'json', + 'xml', + 'html', + ), + ), + ), + 'responseMessages' => + array( + + array( + 'code' => 200, + 'message' => 'Returned on success.', + ), + + array( + 'code' => 404, + 'message' => 'Returned if resource cannot be found.', + ), + ), + ), + + array( + 'method' => 'POST', + 'summary' => 'Create a new resource.', + 'nickname' => 'post_resources', + 'parameters' => + array( + + array( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + 'enum' => + array( + 'json', + 'xml', + 'html', + ), + ), + + array( + 'paramType' => 'form', + 'name' => 'a', + 'type' => 'string', + ), + + array( + 'paramType' => 'form', + 'name' => 'b', + 'type' => 'number', + 'format' => 'float', + ), + + array( + 'paramType' => 'form', + 'name' => 'c', + 'type' => 'string', + 'enum' => + array( + 'x', + 'y', + 'z', + ), + ), + + array( + 'paramType' => 'form', + 'name' => 'd', + 'type' => 'string', + 'format' => 'date-time', + ), + + array( + 'paramType' => 'form', + 'name' => 'e', + 'type' => 'string', + 'format' => 'date', + ), + + array( + 'paramType' => 'form', + 'name' => 'g', + 'type' => 'string', + ), + ), + 'responseMessages' => + array( + array( + 'code' => 200, + 'message' => 'See standard HTTP status code reason for 200', + 'responseModel' => 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsNested', + ), + ), + 'type' => 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsNested', + ), + ), + ), + + array( + 'path' => '/resources/{id}.{_format}', + 'operations' => + array( + + array( + 'method' => 'GET', + 'summary' => 'Retrieve a resource by ID.', + 'nickname' => 'get_resources', + 'parameters' => + array( + + array( + 'paramType' => 'path', + 'name' => 'id', + 'type' => 'string', + 'required' => true, + ), + + array( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + 'enum' => + array( + 'json', + 'xml', + 'html', + ), + ), + ), + 'responseMessages' => + array(), + ), + + array( + 'method' => 'DELETE', + 'summary' => 'Delete a resource by ID.', + 'nickname' => 'delete_resources', + 'parameters' => + array( + + array( + 'paramType' => 'path', + 'name' => 'id', + 'type' => 'string', + 'required' => true, + ), + + array( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + 'enum' => + array( + 'json', + 'xml', + 'html', + ), + ), + ), + 'responseMessages' => + array(), + ), + ), + ), + ), + 'models' => + array( + 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsTest' => + array( + 'id' => 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsTest', + 'description' => 'object (JmsTest)', + 'properties' => + array( + 'foo' => + array( + 'type' => 'string', + 'description' => 'string', + ), + 'bar' => + array( + 'type' => 'string', + 'description' => 'DateTime', + 'format' => 'date-time', + ), + 'number' => + array( + 'type' => 'number', + 'description' => 'double', + 'format' => 'float', + ), + 'arr' => + array( + 'type' => 'array', + 'description' => 'array', + 'items' => array( + 'type' => 'string', + ) + ), + 'nested' => + array( + '$ref' => 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsNested', + ), + 'nested_array' => + array( + 'type' => 'array', + 'description' => 'array of objects (JmsNested)', + 'items' => array( + '$ref' => 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsNested', + ) + ), + ), + 'required' => + array(), + ), + 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsNested' => + array( + 'id' => 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsNested', + 'description' => '', + 'properties' => + array( + 'foo' => + array( + 'type' => 'string', + 'description' => 'DateTime', + 'format' => 'date-time', + ), + 'bar' => + array( + 'type' => 'string', + 'description' => 'string', + ), + 'baz' => + array( + 'type' => 'array', + 'description' => 'Epic description. + +With multiple lines.', + 'items' => array( + 'type' => 'string', + ) + ), + 'circular' => + array( + '$ref' => 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsNested', + ), + 'parent' => + array( + '$ref' => 'Nelmio.ApiDocBundle.Tests.Fixtures.Model.JmsTest', + ), + 'since' => + array( + 'type' => 'string', + 'description' => 'string', + ), + 'until' => + array( + 'type' => 'string', + 'description' => 'string', + ), + 'since_and_until' => + array( + 'type' => 'string', + 'description' => 'string', + ), + ), + 'required' => + array(), + ), + ), + 'produces' => + array(), + 'consumes' => + array(), + 'authorizations' => + array(), + ), + ), + array( + '/other-resources', + array( + 'swaggerVersion' => '1.2', + 'apiVersion' => '3.14', + 'basePath' => '/api', + 'resourcePath' => '/other-resources', + 'apis' => + array( + + array( + 'path' => '/other-resources.{_format}', + 'operations' => + array( + + array( + 'method' => 'GET', + 'summary' => 'List another resource.', + 'nickname' => 'get_other-resources', + 'parameters' => + array( + + array( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + 'enum' => + array( + 'json', + 'xml', + 'html', + ), + ), + ), + 'responseMessages' => + array(), + ), + ), + ), + + array( + 'path' => '/other-resources/{id}.{_format}', + 'operations' => + array( + + array( + 'method' => 'PUT', + 'summary' => 'Update a resource bu ID.', + 'nickname' => 'put_other-resources', + 'parameters' => + array( + + array( + 'paramType' => 'path', + 'name' => 'id', + 'type' => 'string', + 'required' => true, + ), + + array( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + 'enum' => + array( + 'json', + 'xml', + 'html', + ), + ), + ), + 'responseMessages' => + array(), + ), + + array( + 'method' => 'PATCH', + 'summary' => 'Update a resource bu ID.', + 'nickname' => 'patch_other-resources', + 'parameters' => + array( + + array( + 'paramType' => 'path', + 'name' => 'id', + 'type' => 'string', + 'required' => true, + ), + + array( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + 'enum' => + array( + 'json', + 'xml', + 'html', + ), + ), + ), + 'responseMessages' => + array(), + ), + ), + ), + ), + 'models' => + array(), + 'produces' => + array(), + 'consumes' => + array(), + 'authorizations' => + array(), + ), + ), + array( + '/tests', + array ( + 'swaggerVersion' => '1.2', + 'apiVersion' => '3.14', + 'basePath' => '/api', + 'resourcePath' => '/tests', + 'apis' => + array ( + + array ( + 'path' => '/tests.{_format}', + 'operations' => + array ( + + array ( + 'method' => 'GET', + 'summary' => 'index action', + 'nickname' => 'get_tests', + 'parameters' => + array ( + + array ( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + ), + ), + 'responseMessages' => + array ( + ), + ), + + array ( + 'method' => 'GET', + 'summary' => 'index action', + 'nickname' => 'get_tests', + 'parameters' => + array ( + + array ( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + ), + + array ( + 'paramType' => 'query', + 'name' => 'a', + 'type' => 'integer', + ), + + array ( + 'paramType' => 'query', + 'name' => 'b', + 'type' => 'string', + ), + ), + 'responseMessages' => + array ( + ), + ), + + array ( + 'method' => 'POST', + 'summary' => 'create test', + 'nickname' => 'post_tests', + 'parameters' => + array ( + + array ( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + ), + + array ( + 'paramType' => 'query', + 'name' => 'a', + 'type' => 'integer', + ), + + array ( + 'paramType' => 'query', + 'name' => 'b', + 'type' => 'string', + ), + + array ( + 'paramType' => 'form', + 'name' => 'a', + 'type' => 'string', + ), + + array ( + 'paramType' => 'form', + 'name' => 'b', + 'type' => 'string', + ), + + array ( + 'paramType' => 'form', + 'name' => 'c', + 'type' => 'boolean', + 'defaultValue' => false, + ), + + array ( + 'paramType' => 'form', + 'name' => 'd', + 'type' => 'string', + 'defaultValue' => 'DefaultTest', + ), + ), + 'responseMessages' => + array ( + ), + ), + + array ( + 'method' => 'POST', + 'summary' => 'create test', + 'nickname' => 'post_tests', + 'parameters' => + array ( + + array ( + 'paramType' => 'path', + 'name' => '_format', + 'type' => 'string', + 'required' => true, + ), + + array ( + 'paramType' => 'form', + 'name' => 'a', + 'type' => 'string', + ), + + array ( + 'paramType' => 'form', + 'name' => 'b', + 'type' => 'string', + ), + + array ( + 'paramType' => 'form', + 'name' => 'c', + 'type' => 'boolean', + 'defaultValue' => false, + ), + + array ( + 'paramType' => 'form', + 'name' => 'd', + 'type' => 'string', + 'defaultValue' => 'DefaultTest', + ), + ), + 'responseMessages' => + array ( + ), + ), + ), + ), + ), + 'models' => + array ( + ), + 'produces' => + array ( + ), + 'consumes' => + array ( + ), + 'authorizations' => + array ( + ), + ), + ), + ); + } +} \ No newline at end of file