* * 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 Nelmio\ApiDocBundle\Swagger\ModelRegistry; 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', ); /** * @var \Nelmio\ApiDocBundle\Swagger\ModelRegistry */ protected $modelRegistry; public function __construct($namingStategy) { $this->modelRegistry = new ModelRegistry($namingStategy); } /** * @var array */ protected $authConfig = null; public function setAuthenticationConfig(array $config) { $this->authConfig = $config; } /** * 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() { $auth = array(); if ($this->authConfig === null) { return $auth; } $config = $this->authConfig; if ($config['delivery'] === 'http') { return $auth; } $auth['apiKey'] = array( 'type' => 'apiKey', 'passAs' => $config['delivery'], 'keyname' => $config['name'], ); return $auth; } /** * @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' => $this->getAuthorizations(), ); $main = null; $apiBag = array(); foreach ($collection as $item) { /** @var $apiDoc ApiDoc */ $apiDoc = $item['annotation']; $itemResource = $this->stripBasePath($item['resource']); $input = $apiDoc->getInput(); if (!is_array($input)) { $input = array( 'class' => $input, 'paramType' => 'form', ); } elseif (empty($input['paramType'])) { $input['paramType'] = 'form'; } $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; } $data = $apiDoc->toArray(); if (isset($data['filters'])) { $parameters = array_merge($parameters, $this->deriveQueryParameters($data['filters'])); } if (isset($data['parameters'])) { $parameters = array_merge($parameters, $this->deriveParameters($data['parameters'], $input['paramType'])); } $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); } if (isset($prop['type']['collection']) && $prop['type']['collection'] === true) { /* * Without alias: Fully\Qualified\Class\Name[] * With alias: Fully\Qualified\Class\Name[alias] */ $alias = $prop['type']['collectionName']; $newName = sprintf('%s[%s]', $prop['type']['class'], $alias); $collId = $this->registerModel( $newName, array( $alias => array( 'dataType' => null, 'subType' => $prop['type']['class'], 'actualType' => DataTypes::COLLECTION, 'required' => true, 'readonly' => true, 'description' => null, 'default' => null, 'children' => $prop['model'][$alias]['children'], ) ), '' ); $responseModel = array( 'code' => $statusCode, 'message' => $message, 'responseModel' => $collId ); } else { $responseModel = array( 'code' => $statusCode, 'message' => $message, 'responseModel' => $this->registerModel($prop['type']['class'], $prop['model'], ''), ); } $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'] = $this->modelRegistry->getModels(); $this->modelRegistry->clear(); 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) { if (!isset($prop['dataType'])) { $prop['dataType'] = 'string'; } $parameters[] = array( 'paramType' => 'query', 'name' => $name, 'type' => isset($this->typeMap[$prop['dataType']]) ? $this->typeMap[$prop['dataType']] : 'string', 'description' => isset($prop['description']) ? $prop['description'] : null, ); } return $parameters; } /** * Builds a Swagger-compliant parameter list from the provided parameter array. Models are built when necessary. * * @param array $input * @param array $models * * @param string $paramType * * @return array */ protected function deriveParameters(array $input, $paramType = 'form') { $parameters = array(); foreach ($input as $name => $prop) { $type = null; $format = null; $ref = null; $enum = null; $items = null; if (!isset($prop['actualType'])) { $prop['actualType'] = 'string'; } 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'] ); break; case DataTypes::COLLECTION: $type = 'array'; if ($prop['subType'] === null) { $items = array('type' => 'string'); } elseif (isset($this->typeMap[$prop['subType']])) { $items = array('type' => $this->typeMap[$prop['subType']]); } else { $ref = $this->registerModel( $prop['subType'], isset($prop['children']) ? $prop['children'] : null, $prop['description'] ?: $prop['dataType'] ); $items = array( '$ref' => $ref, ); } 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' => $paramType, '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 (isset($prop['default'])) { $parameter['defaultValue'] = $prop['default']; } if (isset($items)) { $parameter['items'] = $items; } if (isset($prop['description'])) { $parameter['description'] = $prop['description']; } $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 * * @internal param $models * @return mixed */ public function registerModel($className, array $parameters = null, $description = '') { return $this->modelRegistry->register($className, $parameters, $description); } /** * @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) { if ('/' === $this->basePath) { return $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); } }