diff --git a/.gitignore b/.gitignore index f6bf53da3..3426cdf85 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ coverage.html /run_server.bat /*.bat /filestorage +app/V1Module/presenters/_autogenerated_annotations_temp.php diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index f75b6c457..348d3f82e 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -2,626 +2,43 @@ namespace App\Console; -use App\Helpers\ApiConfig; -use App\V1Module\Router\MethodRoute; -use Doctrine\DBAL\Connection; -use Doctrine\ORM\EntityManager; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Tools\SchemaTool; -use JsonSerializable; -use Nette\Application\IPresenterFactory; -use Nette\Application\Routers\RouteList; -use Nette\Application\UI\Presenter; -// use Nette\Reflection\ClassType; -// use Nette\Reflection\IAnnotation; -// use Nette\Reflection\Method; -use Nette\Utils\ArrayHash; -use Nette\Utils\Arrays; -use Nette\Utils\Finder; -use Nette\Utils\Json; -use Nette\Utils\Strings; -use ReflectionClass; -use ReflectionException; -use SplFileInfo; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use App\Helpers\Yaml; -use Zenify\DoctrineFixtures\Alice\AliceLoader; +use OpenApi\Generator; -// Completely removed -- needs rewriting for OpenAPI specs -// class GenerateSwagger extends Command -// { -// protected static $defaultName = 'swagger:generate'; -// -// /** -// * @var RouteList -// */ -// private $router; -// -// /** -// * @var IPresenterFactory -// */ -// private $presenterFactory; -// -// /** -// * @var AliceLoader -// */ -// private $fixtureLoader; -// -// /** -// * @var EntityManagerInterface -// */ -// private $em; -// -// /** -// * @var ApiConfig -// */ -// private $apiConfig; -// -// /** -// * @var array -// */ -// private $typeMap = [ -// 'bool' => 'boolean', -// 'boolean' => 'boolean', -// 'int' => 'integer', -// 'integer' => 'integer', -// 'float' => 'number', -// 'number' => 'number', -// 'numeric' => 'number', -// 'numericint' => 'integer', -// 'timestamp' => 'integer', -// 'string' => 'string', -// 'unicode' => ['string', 'unicode'], -// 'email' => ['string', 'email'], -// 'url' => ['string', 'url'], -// 'uri' => ['string', 'uri'], -// 'pattern' => null, -// 'alnum' => ['string', 'alphanumeric'], -// 'alpha' => ['string', 'alphabetic'], -// 'digit' => ['string', 'numeric'], -// 'lower' => ['string', 'lowercase'], -// 'upper' => ['string', 'uppercase'] -// ]; -// -// public function __construct( -// RouteList $router, -// IPresenterFactory $presenterFactory, -// AliceLoader $loader, -// EntityManagerInterface $em, -// ApiConfig $apiConfig -// ) { -// parent::__construct(); -// $this->router = $router; -// $this->presenterFactory = $presenterFactory; -// $this->fixtureLoader = $loader; -// $this->em = $em; -// $this->apiConfig = $apiConfig; -// } -// -// protected function configure() -// { -// $this->setName("swagger:generate")->setDescription("Generate a swagger specification file from existing code"); -// $this->addArgument( -// "source", -// InputArgument::OPTIONAL, -// "A YAML Swagger file to use as a template for the generated file", -// null -// ); -// $this->addOption("save", null, InputOption::VALUE_NONE, "Save the output back to the source file"); -// } -// -// protected function setArrayDefault(&$array, $key, $default) -// { -// if (!array_key_exists($key, $array)) { -// $array[$key] = $default; -// return true; -// } -// -// return false; -// } -// -// protected function execute(InputInterface $input, OutputInterface $output) -// { -// $apiRoutes = $this->findAPIRouteList(); -// -// if (!$apiRoutes) { -// $output->writeln("No suitable routes found"); -// return 1; -// } -// -// $source = $input->getArgument("source"); -// $save = $input->getOption("save"); -// -// if ($save && $source === null) { -// $output->writeln("--save cannot be used without a source file"); -// return 1; -// } -// -// $document = $source ? Yaml::parse(file_get_contents($source)) : []; -// $basePath = ltrim(Arrays::get($document, "basePath", "/v1"), "/"); -// -// $this->setArrayDefault($document, "info", []); -// $document["info"]["version"] = $this->apiConfig->getVersion(); -// -// $this->setArrayDefault($document, "paths", []); -// $paths = &$document["paths"]; -// -// $this->setArrayDefault($document, "tags", []); -// $tags = &$document["tags"]; -// -// $defaultSecurity = null; -// $securityDefinitions = []; -// -// if (array_key_exists('securityDefinitions', $document)) { -// $securityDefinitions = array_keys($document['securityDefinitions']); -// -// if (count($securityDefinitions) > 0) { -// $defaultSecurity = $securityDefinitions[0]; -// } -// } -// -// foreach ($apiRoutes as $routeData) { -// $route = $routeData["route"]; -// $parentRoute = $routeData["parent"]; -// -// $method = self::getPropertyValue($route, "method"); -// $actualRoute = self::getPropertyValue($route, "route"); -// -// $metadata = self::getPropertyValue($actualRoute, "metadata"); -// $mask = self::getPropertyValue($actualRoute, "mask"); -// -// if (!Strings::startsWith($mask, $basePath)) { -// continue; -// } -// -// $mask = substr(str_replace(["<", ">"], ["{", "}"], $mask), strlen($basePath)); -// -// $this->setArrayDefault($paths, $mask, []); -// $this->setArrayDefault($paths[$mask], strtolower($method), []); -// -// // TODO hack - we need a better way of getting module names from nested RouteList objects -// $module = "V1:" . self::getPropertyValue($parentRoute, "module"); -// $this->fillPathEntry( -// $metadata, -// $paths[$mask][strtolower($method)], -// $module, -// $defaultSecurity, -// function ($text) use ($output, $method, $mask) { -// $output->writeln("Endpoint $method $mask: $text"); -// } -// ); -// $this->makePresenterTag($metadata, $module, $tags, $paths[$mask][strtolower($method)]); -// } -// -// $this->setArrayDefault($document, "definitions", []); -// $this->fillEntityExamples($document["definitions"]); -// -// $yaml = Yaml::dump($document, 10, 2); -// $yaml = Strings::replace($yaml, '/(?<=parameters:)\s*\{\s*\}/', " [ ]"); // :-! -// $yaml = Strings::replace($yaml, '/(?<=tags:)\s*\{\s*\}/', " [ ]"); // :-! -// -// foreach ($securityDefinitions as $definition) { -// $yaml = Strings::replace($yaml, '/(?<=' . $definition . ':)\s*\{\s*\}/', " [ ]"); // :-! -// } -// -// // $output->write($yaml); -// -// if ($save) { -// file_put_contents($source, $yaml); -// } -// -// return 0; -// } -// -// private function fillPathEntry( -// array $metadata, -// array &$entry, -// $module, -// $defaultSecurity = null, -// callable $warning = null -// ) { -// if ($warning === null) { -// $warning = function ($text) { -// }; -// } -// -// if (count($entry["tags"]) > 1) { -// $warning("Multiple tags"); -// } -// -// $presenterName = $module . $metadata["presenter"]["value"]; -// $action = $metadata["action"]["value"] ?: "default"; -// -// /** @var Presenter $presenter */ -// $presenter = $this->presenterFactory->createPresenter($presenterName); -// $methodName = $presenter->formatActionMethod($action); -// -// try { -// $method = Method::from(get_class($presenter), $methodName); -// } catch (ReflectionException $exception) { -// return null; -// } -// -// $annotations = $method->getAnnotations(); -// -// $entry["description"] = $method->getDescription() ?: ""; -// $this->setArrayDefault($entry, "parameters", []); -// $this->setArrayDefault($entry, "responses", []); -// -// $existingParams = []; -// -// foreach ($entry["parameters"] as $paramEntry) { -// $existingParams[$paramEntry["name"]] = false; -// } -// -// foreach (Arrays::get($annotations, "Param", []) as $annotation) { -// if ($annotation instanceof ArrayHash) { -// $annotation = get_object_vars($annotation); -// } -// -// $required = Arrays::get($annotation, "required", false); -// $validation = Arrays::get($annotation, "validation", ""); -// $in = $annotation["type"] === "post" ? "formData" : "query"; -// $description = Arrays::get($annotation, "description", ""); -// $this->fillParamEntry($entry, $annotation["name"], $in, $required, $validation, $description); -// -// $existingParams[$annotation["name"]] = true; -// } -// -// $parameterAnnotations = Arrays::get($annotations, "param", []); -// -// foreach ($method->getParameters() as $methodParameter) { -// $in = $methodParameter->isOptional() ? "query" : "path"; -// $description = ""; -// $validation = "string"; -// $existingParams[$methodParameter->getName()] = true; -// -// foreach ($parameterAnnotations as $annotation) { -// $annotationParts = explode(" ", $annotation, 3); -// $firstPart = Arrays::get($annotationParts, 0, null); -// $secondPart = Arrays::get($annotationParts, 1, null); -// -// if ($secondPart === "$" . $methodParameter->getName()) { -// $validation = $firstPart; -// } else { -// if ($firstPart === "$" . $methodParameter->getName()) { -// $validation = $secondPart; -// } else { -// continue; -// } -// } -// -// $description = Arrays::get($annotationParts, 2, ""); -// } -// -// $this->fillParamEntry( -// $entry, -// $methodParameter->getName(), -// $in, -// !$methodParameter->isOptional(), -// $validation ?? "", -// $description -// ); -// } -// -// foreach ($existingParams as $param => $exists) { -// if (!$exists) { -// $warning("Unknown parameter $param"); -// } -// } -// -// $this->setArrayDefault($entry["responses"], "200", []); -// -// /** @var ?IAnnotation $loggedInAnnotation */ -// $loggedInAnnotation = $method->getAnnotation("LoggedIn"); -// $isLoginNeeded = $presenter->getReflection()->getAnnotation("LoggedIn") || $loggedInAnnotation; -// -// if ($isLoginNeeded) { -// $this->setArrayDefault($entry["responses"], "401", []); -// -// if ($defaultSecurity !== null) { -// $this->setArrayDefault($entry, 'security', [[$defaultSecurity => []]]); -// } -// } elseif (array_key_exists("401", $entry["responses"])) { -// $warning( -// sprintf( -// "Method %s is not annotated with @LoggedIn, but corresponding endpoint has 401 in its response list", -// $method->name -// ) -// ); -// } -// -// /** @var ?IAnnotation $userIsAllowedAnnotation */ -// $userIsAllowedAnnotation = $method->getAnnotation("UserIsAllowed"); -// /** @var ?IAnnotation $roleAnnotation */ -// $roleAnnotation = $method->getAnnotation("Role"); -// $isAuthFailurePossible = $userIsAllowedAnnotation -// || $presenter->getReflection()->getAnnotation("Role") -// || $roleAnnotation; -// -// if ($isAuthFailurePossible) { -// $this->setArrayDefault($entry["responses"], "403", []); -// } elseif (array_key_exists("403", $entry["responses"])) { -// $warning( -// sprintf( -// "Method %s is not annotated with @UserIsAllowed, but corresponding endpoint has 403 in its response list", -// $method->name -// ) -// ); -// } -// -// return $entry; -// } -// -// /** -// * @param array $entry -// * @param string $name -// * @param string $in -// * @param bool $required -// * @param string $validation -// * @param string $description -// */ -// private function fillParamEntry(array &$entry, $name, $in, $required, $validation, $description) -// { -// $paramEntryFound = false; -// -// foreach ($entry["parameters"] as $i => $parameter) { -// if ($parameter["name"] === $name) { -// $paramEntry = &$entry["parameters"][$i]; -// $paramEntryFound = true; -// break; -// } -// } -// -// if (!$paramEntryFound) { -// $entry["parameters"][] = [ -// "name" => $name -// ]; -// -// $paramEntry = &$entry["parameters"][count($entry["parameters"]) - 1]; -// } -// -// $paramEntry["in"] = $in; -// $paramEntry["required"] = $required; -// -// if ($in === "path") { -// $paramEntry["required"] = true; -// } else { -// if ($in === "query") { -// $this->setArrayDefault($paramEntry, "required", false); -// } -// } -// -// $paramEntry = array_merge($paramEntry, $this->translateType($validation)); -// $paramEntry["description"] = $description; -// } -// -// private function findAPIRouteList() -// { -// $queue = [$this->router]; -// -// while (count($queue) != 0) { -// $cursor = array_shift($queue); -// -// if ($cursor instanceof RouteList) { -// foreach ($cursor as $item) { -// if ($item instanceof MethodRoute) { -// yield [ -// "parent" => $cursor, -// "route" => $item -// ]; -// } -// -// if ($item instanceof RouteList) { -// array_push($queue, $item); -// } -// } -// } -// } -// -// return null; -// } -// -// private static function getPropertyValue($object, $propertyName) -// { -// $class = new ReflectionClass($object); -// -// do { -// try { -// $property = $class->getProperty($propertyName); -// } catch (ReflectionException $exception) { -// $class = $class->getParentClass(); -// $property = null; -// } -// } while ($property === null && $class !== null); -// -// $property->setAccessible(true); -// return $property->getValue($object); -// } -// -// private function translateType(string $type): array -// { -// if (!$type) { -// return []; -// } -// -// $validation = null; -// -// if (Strings::contains($type, ':')) { -// list($type, $validation) = explode(':', $type); -// } -// -// $translation = Arrays::get($this->typeMap, $type, null); -// if (is_array($translation)) { -// $typeInfo = [ -// 'type' => $translation[0], -// 'format' => $translation[1] -// ]; -// } else { -// if ($translation !== null) { -// $typeInfo = [ -// 'type' => $translation -// ]; -// } else { -// return []; -// } -// } -// -// if ($validation && Strings::contains($validation, '..')) { -// list($min, $max) = explode('..', $validation); -// if ($min) { -// $typeInfo['minLength'] = intval($min); -// } -// -// if ($max) { -// $typeInfo['maxLength'] = intval($max); -// } -// } else { -// if ($validation) { -// $typeInfo['minLength'] = intval($validation); -// $typeInfo['maxLength'] = intval($validation); -// } -// } -// -// return $typeInfo; -// } -// -// private function fillEntityExamples(array &$target) -// { -// // Load fixtures from the "base" and "demo" groups -// $fixtureDir = __DIR__ . "/../../fixtures"; -// -// $finder = Finder::findFiles("*.neon", "*.yaml", "*.yml") -// ->in($fixtureDir . "/base", $fixtureDir . "/demo"); -// -// $files = []; -// -// /** @var SplFileInfo $file */ -// foreach ($finder as $file) { -// $files[] = $file->getRealPath(); -// } -// -// sort($files); -// -// // Create a DB in memory so that we don't mess up the default one -// $em = EntityManager::create( -// new Connection( -// ['url' => 'sqlite://:memory:'], -// $this->em->getConnection()->getDriver(), -// $this->em->getConfiguration(), -// $this->em->getEventManager() -// ), -// $this->em->getConfiguration(), -// $this->em->getEventManager() -// ); -// -// $schemaTool = new SchemaTool($em); -// $schemaTool->createSchema($em->getMetadataFactory()->getAllMetadata()); -// -// // Load fixtures and persist them -// foreach ($files as $file) { -// $loadedEntities = $this->fixtureLoader->load($file); -// -// foreach ($loadedEntities as $entity) { -// $em->persist($entity); -// } -// } -// -// $em->flush(); -// $em->clear(); -// -// $entityExamples = []; -// foreach ($em->getMetadataFactory()->getAllMetadata() as $metadata) { -// $name = $metadata->getName(); -// $reflection = ClassType::from($name); -// if (Strings::startsWith($name, "App") && !$reflection->isAbstract()) { -// $entityExamples[] = $em->getRepository($name)->findAll()[0]; -// } -// } -// -// // Dump serializable entities into the document -// foreach ($entityExamples as $entity) { -// if ($entity instanceof JsonSerializable) { -// $entityClass = ClassType::from($entity); -// $entityData = Json::decode(Json::encode($entity), Json::FORCE_ARRAY); -// $this->updateEntityEntry($target, $entityClass->getShortName(), $entityData); -// } -// } -// } -// -// private function updateEntityEntry(array &$entry, $key, $value) -// { -// $type = is_array($value) -// ? (Arrays::isList($value) ? "array" : "object") -// : gettype($value); -// -// $this->setArrayDefault($entry, $key, []); -// -// // If a property value is a reference, just skip it -// if (count($entry[$key]) == 1 && array_key_exists('$ref', $entry[$key])) { -// return; -// } -// -// if ($type === "object") { -// $entry[$key]["type"] = "object"; -// $this->setArrayDefault($entry[$key], "properties", []); -// -// foreach ($value as $objectKey => $objectValue) { -// $this->updateEntityEntry($entry[$key]["properties"], $objectKey, $objectValue); -// } -// } else { -// if ($type === "array") { -// $entry[$key]["type"] = "array"; -// $this->setArrayDefault($entry[$key], "items", []); -// -// if (count($value) > 0) { -// $this->updateEntityEntry($entry[$key], "items", $value[0]); -// } -// } else { -// $this->setArrayDefault($entry[$key], "type", $type); -// if ($entry[$key]["type"] === $type && $value !== null) { -// $entry[$key]["example"] = $value; -// } -// } -// } -// } -// -// private function makePresenterTag($metadata, $module, array &$tags, array &$entry) -// { -// $presenterName = $metadata["presenter"]["value"]; -// $fullPresenterName = $module . $presenterName; -// -// /** @var Presenter $presenter */ -// $presenter = $this->presenterFactory->createPresenter($fullPresenterName); -// -// $tag = strtolower(Strings::replace($presenterName, '/(?!^)([A-Z])/', '-\1')); -// $tagEntry = []; -// $tagEntryFound = false; -// -// foreach ($tags as $i => $tagEntry) { -// if ($tagEntry["name"] === $tag) { -// $tagEntryFound = true; -// $tagEntry = &$tags[$i]; -// break; -// } -// } -// -// if (!$tagEntryFound) { -// $tags[] = [ -// "name" => $tag -// ]; -// -// $tagEntry = &$tags[count($tags) - 1]; -// } -// -// $tagEntry["description"] = (new ClassType($presenter))->getDescription() ?: ""; -// -// $this->setArrayDefault($entry, "tags", []); -// $entry["tags"][] = $tag; -// $entry["tags"] = array_unique($entry["tags"]); -// } -// } +/** + * Command that consumes a temporary file containing endpoint annotations and generates a swagger documentation. + */ +class GenerateSwagger extends Command +{ + protected static $defaultName = 'swagger:generate'; + + protected function configure() + { + $this->setName(self::$defaultName)->setDescription( + 'Generate an OpenAPI documentation from the temporary file created by the swagger:annotate command.' + . ' The temporary file is deleted afterwards.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $path = __DIR__ . '/../V1Module/presenters/_autogenerated_annotations_temp.php'; + + // check if file exists + if (!file_exists($path)) { + $output->writeln("Error in GenerateSwagger: Temp annotation file not found."); + return Command::FAILURE; + } + + $openapi = Generator::scan([$path]); + + $output->writeln($openapi->toYaml()); + + // delete the temp file + unlink($path); + + return Command::SUCCESS; + } +} diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php new file mode 100644 index 000000000..1c9c9321b --- /dev/null +++ b/app/commands/SwaggerAnnotator.php @@ -0,0 +1,159 @@ +setName(self::$defaultName)->setDescription( + "Extracts endpoint method annotations and puts them into a temporary file that can be used to generate" + . " an OpenAPI documentation. The file is located at {$filePath}" + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + // create a temporary file containing transpiled annotations usable by the external library (Swagger-PHP) + $fileBuilder = new TempAnnotationFileBuilder(self::$autogeneratedAnnotationFilePath); + $fileBuilder->startClass('__Autogenerated_Annotation_Controller__', '1.0', 'ReCodEx API'); + + // get all routes of the api + $routes = $this->getRoutes(); + foreach ($routes as $routeObj) { + // extract class and method names of the endpoint + $metadata = $this->extractMetadata($routeObj); + $route = $this->extractRoute($routeObj); + $className = self::$presenterNamespace . $metadata['class']; + + // extract data from the existing annotations + $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method'], $route); + + // add an empty method to the file with the transpiled annotations + $fileBuilder->addAnnotatedMethod($metadata['method'], $annotationData->toSwaggerAnnotations($route)); + } + $fileBuilder->endClass(); + + return Command::SUCCESS; + } catch (Exception $e) { + $output->writeln("Error in SwaggerAnnotator: {$e->getMessage()}"); + + return Command::FAILURE; + } + } + + /** + * Finds all route objects of the API + * @return array Returns an array of all found route objects. + */ + private function getRoutes(): array + { + $router = \App\V1Module\RouterFactory::createRouter(); + + // find all route object using a queue + $queue = [$router]; + $routes = []; + while (count($queue) != 0) { + $cursor = array_shift($queue); + + if ($cursor instanceof RouteList) { + foreach ($cursor->getRouters() as $item) { + // lists contain routes or nested lists + if ($item instanceof RouteList) { + array_push($queue, $item); + } else { + // the first route is special and holds no useful information for annotation + if (get_parent_class($item) !== MethodRoute::class) { + continue; + } + + $routes[] = $this->getPropertyValue($item, "route"); + } + } + } + } + + return $routes; + } + + /** + * Extracts the route string from a route object. Replaces '<..>' in the route with '{...}'. + * @param mixed $routeObj + */ + private function extractRoute($routeObj): string + { + $mask = self::getPropertyValue($routeObj, "mask"); + + // sample: replaces '/users/' with '/users/{id}' + $mask = str_replace(["<", ">"], ["{", "}"], $mask); + return "/" . $mask; + } + + /** + * Extracts the class and method names of the endpoint handler. + * @param mixed $routeObj The route object representing the endpoint. + * @return string[] Returns a dictionary [ "class" => ..., "method" => ...] + */ + private function extractMetadata($routeObj) + { + $metadata = self::getPropertyValue($routeObj, "metadata"); + $presenter = $metadata["presenter"]["value"]; + $action = $metadata["action"]["value"]; + + // if the name is empty, the method will be called 'actionDefault' + if ($action === null) { + $action = "default"; + } + + return [ + "class" => $presenter . "Presenter", + "method" => "action" . ucfirst($action), + ]; + } + + /** + * Helper function that can extract a property value from an arbitrary object where + * the property can be private. + * @param mixed $object The object to extract from. + * @param string $propertyName The name of the property. + * @return mixed Returns the value of the property. + */ + private static function getPropertyValue($object, string $propertyName): mixed + { + $class = new ReflectionClass($object); + + do { + try { + $property = $class->getProperty($propertyName); + } catch (ReflectionException $exception) { + $class = $class->getParentClass(); + $property = null; + } + } while ($property === null && $class !== null); + + $property->setAccessible(true); + return $property->getValue($object); + } +} diff --git a/app/config/config.neon b/app/config/config.neon index fe0ef69b1..3f9e704eb 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -318,6 +318,8 @@ services: - App\Console\AsyncJobsUpkeep(%async.upkeep%) - App\Console\GeneralStatsNotification - App\Console\ExportDatabase + - App\Console\GenerateSwagger + - App\Console\SwaggerAnnotator - App\Console\CleanupLocalizedTexts - App\Console\CleanupExerciseConfigs - App\Console\CleanupPipelineConfigs diff --git a/app/helpers/Swagger/AnnotationData.php b/app/helpers/Swagger/AnnotationData.php new file mode 100644 index 000000000..977a5a1fa --- /dev/null +++ b/app/helpers/Swagger/AnnotationData.php @@ -0,0 +1,91 @@ +httpMethod = $httpMethod; + $this->pathParams = $pathParams; + $this->queryParams = $queryParams; + $this->bodyParams = $bodyParams; + } + + /** + * Creates a method annotation string parsable by the swagger generator. + * Example: if the method name is 'Put', the method will return '@OA\\PUT'. + * @return string Returns the method annotation. + */ + private function getHttpMethodAnnotation(): string + { + // sample: converts 'PUT' to 'Put' + $httpMethodString = ucfirst(strtolower($this->httpMethod->name)); + return "@OA\\" . $httpMethodString; + } + + /** + * Creates a JSON request body annotation string parsable by the swagger generator. + * Example: if the request body contains only the 'url' property, this method will produce: + * '@OA\RequestBody(@OA\MediaType(mediaType="application/json",@OA\Schema(@OA\Property(property="url",type="string"))))' + * @return string|null Returns the annotation string or null, if there are no body parameters. + */ + private function getBodyAnnotation(): string | null + { + if (count($this->bodyParams) === 0) { + return null; + } + + ///TODO: The swagger generator only supports JSON due to the hardcoded mediaType below + $head = '@OA\RequestBody(@OA\MediaType(mediaType="application/json",@OA\Schema'; + $body = new ParenthesesBuilder(); + + foreach ($this->bodyParams as $bodyParam) { + $body->addValue($bodyParam->toPropertyAnnotation()); + } + + return $head . $body->toString() . "))"; + } + + /** + * Converts the extracted annotation data to a string parsable by the Swagger-PHP library. + * @param string $route The route of the handler this set of data represents. + * @return string Returns the transpiled annotations on a single line. + */ + public function toSwaggerAnnotations(string $route) + { + $httpMethodAnnotation = $this->getHttpMethodAnnotation(); + $body = new ParenthesesBuilder(); + $body->addKeyValue("path", $route); + + foreach ($this->pathParams as $pathParam) { + $body->addValue($pathParam->toParameterAnnotation()); + } + foreach ($this->queryParams as $queryParam) { + $body->addValue($queryParam->toParameterAnnotation()); + } + + $jsonProperties = $this->getBodyAnnotation(); + if ($jsonProperties !== null) { + $body->addValue($jsonProperties); + } + + ///TODO: A placeholder for the response type. This has to be replaced with the autogenerated meta-view + /// response data structure in the future. + $body->addValue('@OA\Response(response="200",description="The data")'); + return $httpMethodAnnotation . $body->toString(); + } +} diff --git a/app/helpers/Swagger/AnnotationHelper.php b/app/helpers/Swagger/AnnotationHelper.php new file mode 100644 index 000000000..1b2ba8840 --- /dev/null +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -0,0 +1,249 @@ +getMethod($methodName); + } + + /** + * Searches an array of annotations for any line starting with a valid HTTP method. + * @param array $annotations An array of annotations. + * @return \App\Helpers\Swagger\HttpMethods|null Returns the HTTP method or null if none present. + */ + private static function extractAnnotationHttpMethod(array $annotations): HttpMethods | null + { + // get string names of the enumeration + $cases = HttpMethods::cases(); + $methods = []; + foreach ($cases as $case) { + $methods["@{$case->name}"] = $case; + } + + // check if the annotations have an http method + foreach ($methods as $methodString => $methodEnum) { + if (in_array($methodString, $annotations)) { + return $methodEnum; + } + } + + return null; + } + + /** + * Extracts standart doc comments from endpoints, such as '@param string $id An identifier'. + * Based on the HTTP route of the endpoint, the extracted param can be identified as either a path or + * query parameter. + * @param array $annotations An array of annotations. + * @param string $route The HTTP route of the endpoint. + * @return array Returns an array of AnnotationParameterData objects describing the parameters. + */ + private static function extractStandardAnnotationParams(array $annotations, string $route): array + { + $routeParams = self::getRoutePathParamNames($route); + + $params = []; + foreach ($annotations as $annotation) { + // assumed that all query parameters have a @param annotation + if (str_starts_with($annotation, "@param")) { + // sample: @param string $id Identifier of the user + $tokens = explode(" ", $annotation); + $type = $tokens[1]; + // assumed that all names start with $ + $name = substr($tokens[2], 1); + $description = implode(" ", array_slice($tokens, 3)); + + // figure out where the parameter is located + $location = 'query'; + if (in_array($name, $routeParams)) { + $location = 'path'; + } + + $descriptor = new AnnotationParameterData($type, $name, $description, $location); + $params[] = $descriptor; + } + } + return $params; + } + + /** + * Converts an array of assignment string to an associative array. + * @param array $expressions An array containing values in the following format: 'key="value"'. + * @return array Returns an associative array made from the string array. + */ + private static function stringArrayToAssociativeArray(array $expressions): array + { + $dict = []; + //sample: [ 'name="uiData"', 'validation="array|null"' ] + foreach ($expressions as $expression) { + $tokens = explode('="', $expression); + $name = $tokens[0]; + // remove the '"' at the end + $value = substr($tokens[1], 0, -1); + $dict[$name] = $value; + } + return $dict; + } + + /** + * Extracts annotation parameter data from Nette annotations starting with the '@Param' prefix. + * @param array $annotations An array of annotations. + * @return array Returns an array of AnnotationParameterData objects describing the parameters. + */ + private static function extractNetteAnnotationParams(array $annotations): array + { + $bodyParams = []; + $prefix = "@Param"; + foreach ($annotations as $annotation) { + // assumed that all body parameters have a @Param annotation + if (str_starts_with($annotation, $prefix)) { + // sample: @Param(type="post", name="uiData", validation="array|null", + // description="Structured user-specific UI data") + // remove '@Param(' from the start and ')' from the end + $body = substr($annotation, strlen($prefix) + 1, -1); + $tokens = explode(", ", $body); + $values = self::stringArrayToAssociativeArray($tokens); + $descriptor = new AnnotationParameterData( + $values["validation"], + $values["name"], + $values["description"], + $values["type"] + ); + $bodyParams[] = $descriptor; + } + } + return $bodyParams; + } + + /** + * Returns all method annotation lines as an array. + * Lines not starting with '@' are assumed to be continuations of a parent line starting with @ (or the initial + * line not starting with '@') and are merged into a single line. + * @param string $className The name of the containing class. + * @param string $methodName The name of the method. + * @return array Returns an array of the annotation lines. + */ + private static function getMethodAnnotations(string $className, string $methodName): array + { + $annotations = self::getMethod($className, $methodName)->getDocComment(); + $lines = preg_split("/\r\n|\n|\r/", $annotations); + + // trims whitespace and asterisks + // assumes that asterisks are not used in some meaningful way at the beginning and end of a line + foreach ($lines as &$line) { + $line = trim($line); + $line = trim($line, "*"); + $line = trim($line); + } + + // removes the first and last line + // assumes that the first line is '/**' and the last line '*/' (or '/' after trimming) + $lines = array_slice($lines, 1, -1); + + $merged = []; + for ($i = 0; $i < count($lines); $i++) { + $line = $lines[$i]; + + // skip lines not starting with '@' + if ($line[0] !== "@") { + continue; + } + + // merge lines not starting with '@' with their parent lines starting with '@' + while ($i + 1 < count($lines) && $lines[$i + 1][0] !== "@") { + $line .= " " . $lines[$i + 1]; + $i++; + } + + $merged[] = $line; + } + + return $merged; + } + + /** + * Extracts strings enclosed by curly brackets. + * @param string $route The source string. + * @return array Returns the tokens extracted from the brackets. + */ + private static function getRoutePathParamNames(string $route): array + { + // sample: from '/users/{id}/{name}' generates ['id', 'name'] + preg_match_all('/\{([A-Za-z0-9 ]+?)\}/', $route, $out); + return $out[1]; + } + + /** + * Extracts the annotation data of an endpoint. The data contains request parameters based on their type + * and the HTTP method. + * @param string $className The name of the containing class. + * @param string $methodName The name of the endpoint method. + * @param string $route The route to the method. + * @throws Exception Thrown when the parser encounters an unknown parameter location (known locations are + * path, query and post) + * @return \App\Helpers\Swagger\AnnotationData Returns a data object containing the parameters and HTTP method. + */ + public static function extractAnnotationData(string $className, string $methodName, string $route): AnnotationData + { + $methodAnnotations = self::getMethodAnnotations($className, $methodName); + + $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); + $standardAnnotationParams = self::extractStandardAnnotationParams($methodAnnotations, $route); + $netteAnnotationParams = self::extractNetteAnnotationParams($methodAnnotations); + $params = array_merge($standardAnnotationParams, $netteAnnotationParams); + + $pathParams = []; + $queryParams = []; + $bodyParams = []; + + foreach ($params as $param) { + if ($param->location === 'path') { + $pathParams[] = $param; + } elseif ($param->location === 'query') { + $queryParams[] = $param; + } elseif ($param->location === 'post') { + $bodyParams[] = $param; + } else { + throw new Exception("Error in extractAnnotationData: Unknown param location: {$param->location}"); + } + } + + + $data = new AnnotationData($httpMethod, $pathParams, $queryParams, $bodyParams); + return $data; + } + + /** + * Filters annotation lines starting with a prefix. + * @param array $annotations An array of annotations. + * @param string $type The prefix with which the lines should start, such as '@param'. + * @return array Returns an array of filtered annotations. + */ + public static function filterAnnotations(array $annotations, string $type) + { + $rows = []; + foreach ($annotations as $annotation) { + if (str_starts_with($annotation, $type)) { + $rows[] = $annotation; + } + } + return $rows; + } +} diff --git a/app/helpers/Swagger/AnnotationParameterData.php b/app/helpers/Swagger/AnnotationParameterData.php new file mode 100644 index 000000000..d915f32d3 --- /dev/null +++ b/app/helpers/Swagger/AnnotationParameterData.php @@ -0,0 +1,141 @@ + 'boolean', + 'boolean' => 'boolean', + 'array' => 'array', + 'int' => 'integer', + 'integer' => 'integer', + 'float' => 'number', + 'number' => 'number', + 'numeric' => 'number', + 'numericint' => 'integer', + 'timestamp' => 'integer', + 'string' => 'string', + 'unicode' => 'string', + 'email' => 'string', + 'url' => 'string', + 'uri' => 'string', + 'pattern' => null, + 'alnum' => 'string', + 'alpha' => 'string', + 'digit' => 'string', + 'lower' => 'string', + 'upper' => 'string', + ]; + + public function __construct( + string | null $dataType, + string $name, + string | null $description, + string $location + ) { + $this->dataType = $dataType; + $this->name = $name; + $this->description = $description; + $this->location = $location; + } + + private function isDatatypeNullable(): bool + { + // if the dataType is not specified (it is null), it means that the annotation is not + // complete and defaults to a non nullable string + if ($this->dataType === null) { + return false; + } + + // assumes that the typename ends with '|null' + if (str_ends_with($this->dataType, self::$nullableSuffix)) { + return true; + } + + return false; + } + + /** + * Returns the swagger type associated with the annotation data type. + * @return string Returns the name of the swagger type. + */ + private function getSwaggerType(): string + { + // if the type is not specified, default to a string + $type = 'string'; + $typename = $this->dataType; + if ($typename !== null) { + if ($this->isDatatypeNullable()) { + $typename = substr($typename, 0, -strlen(self::$nullableSuffix)); + } + + if (self::$typeMap[$typename] === null) { + ///TODO: Return the commented exception below once the meta-view formats are implemented. + /// This detaults to strings because custom types like 'email' are not supported yet. + return 'string'; + } + //throw new \InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); + + $type = self::$typeMap[$typename]; + } + return $type; + } + + /** + * Generates swagger schema annotations based on the data type. + * @return string Returns the annotation. + */ + private function generateSchemaAnnotation(): string + { + $head = "@OA\\Schema"; + $body = new ParenthesesBuilder(); + + $body->addKeyValue("type", $this->getSwaggerType()); + return $head . $body->toString(); + } + + /** + * Converts the object to a @OA\Parameter(...) annotation string + */ + public function toParameterAnnotation(): string + { + $head = "@OA\\Parameter"; + $body = new ParenthesesBuilder(); + + $body->addKeyValue("name", $this->name); + $body->addKeyValue("in", $this->location); + $body->addKeyValue("required", !$this->isDatatypeNullable()); + if ($this->description !== null) { + $body->addKeyValue("description", $this->description); + } + + $body->addValue($this->generateSchemaAnnotation()); + + return $head . $body->toString(); + } + + /** + * Generates swagger property annotations based on the data type. + * @return string Returns the annotation. + */ + public function toPropertyAnnotation(): string + { + $head = "@OA\\Property"; + $body = new ParenthesesBuilder(); + + ///TODO: Once the meta-view formats are implemented, add support for property nullability here. + $body->addKeyValue("property", $this->name); + $body->addKeyValue("type", $this->getSwaggerType()); + return $head . $body->toString(); + } +} diff --git a/app/helpers/Swagger/HttpMethods.php b/app/helpers/Swagger/HttpMethods.php new file mode 100644 index 000000000..8bb508daf --- /dev/null +++ b/app/helpers/Swagger/HttpMethods.php @@ -0,0 +1,14 @@ +tokens = []; + } + + /** + * Add a token inside the parentheses in the format of: key="value" + * @param string $key A string key. + * @param mixed $value A value that will be stringified. + * @return \App\Helpers\Swagger\ParenthesesBuilder Returns the builder object. + */ + public function addKeyValue(string $key, mixed $value): ParenthesesBuilder + { + $valueString = strval($value); + // strings need to be wrapped in quotes + if (is_string($value)) { + $valueString = "\"{$value}\""; + // convert bools to strings + } elseif (is_bool($value)) { + $valueString = ($value ? "true" : "false"); + } + + $assignment = "{$key}={$valueString}"; + return $this->addValue($assignment); + } + + /** + * Add a string token inside the parentheses. + * @param string $value The token to be added. + * @return \App\Helpers\Swagger\ParenthesesBuilder Returns the builder object. + */ + public function addValue(string $value): ParenthesesBuilder + { + $this->tokens[] = $value; + return $this; + } + + public function toString(): string + { + return '(' . implode(',', $this->tokens) . ')'; + } +} diff --git a/app/helpers/Swagger/TempAnnotationFileBuilder.php b/app/helpers/Swagger/TempAnnotationFileBuilder.php new file mode 100644 index 000000000..bd72ccb3e --- /dev/null +++ b/app/helpers/Swagger/TempAnnotationFileBuilder.php @@ -0,0 +1,100 @@ +content = ""; + $this->methodEntries = 0; + $this->filename = $filename; + $this->initFile(); + } + + /** + * Initializes the file, adding the namespace and import statements. + */ + private function initFile() + { + $this->content .= "content .= "/// THIS FILE WAS AUTOGENERATED\n"; + $this->content .= "namespace App\V1Module\Presenters;\n"; + $this->content .= "use OpenApi\Annotations as OA;\n"; + } + + /** + * Creates an annotation describing the swagger version and title used. + * @param string $version The version of swagger. + * @param string $title The title of the document. + * @return string Returns the annotation. + */ + private function createInfoAnnotation(string $version, string $title) + { + $head = "@OA\\Info"; + $body = new ParenthesesBuilder(); + $body->addKeyValue("version", $version); + $body->addKeyValue("title", $title); + return $head . $body->toString(); + } + + private function writeAnnotationLineWithComments(string $annotationLine) + { + $this->content .= "/**\n"; + $this->content .= "* {$annotationLine}\n"; + $this->content .= "*/\n"; + } + + /** + * Creates a class that contains all the endpoint annotation methods. + * Should only be called once as the first method. + * @param string $className The name of the class. + * @param string $version The version of swagger. + * @param string $title The name of the swagger document. + */ + public function startClass(string $className, string $version, string $title) + { + $this->writeAnnotationLineWithComments($this->createInfoAnnotation($version, $title)); + $this->content .= "class {$className} {\n"; + } + + /** + * Ends the class and writes the contents to the disk. + */ + public function endClass() + { + $this->content .= "}\n"; + $this->close(); + } + + /** + * Adds an annotated method to the class. + * @param string $methodName The name of the method. This does not have to be unique. + * @param string $annotationLine The annotation line of the method. + */ + public function addAnnotatedMethod(string $methodName, string $annotationLine) + { + $this->writeAnnotationLineWithComments($annotationLine); + $this->content .= "public function {$methodName}{$this->methodEntries}() {}\n"; + $this->methodEntries++; + } + + /** + * Creates a file and adds the swagger content. + */ + private function close() + { + $file = fopen($this->filename, "w"); + fwrite($file, $this->content); + fflush($file); + fclose($file); + } +} diff --git a/composer.json b/composer.json index 87f962956..a326aed96 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,8 @@ "nelmio/alice": "^3.8", "ramsey/uuid-doctrine": "^2.0", "eluceo/ical": "^2.7", - "league/commonmark": "^2.3" + "league/commonmark": "^2.3", + "zircote/swagger-php": "^4.10" }, "require-dev": { "mockery/mockery": "@stable", diff --git a/composer.lock b/composer.lock index 5f9788984..70da9f231 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e291a6441a13f5ee3087b3c9fb9766f5", + "content-hash": "cf7677a8572cdc148b2617c231b9228f", "packages": [ { "name": "behat/transliterator", @@ -426,16 +426,16 @@ }, { "name": "doctrine/annotations", - "version": "1.14.3", + "version": "1.14.4", "source": { "type": "git", "url": "https://github.com/doctrine/annotations.git", - "reference": "fb0d71a7393298a7b232cbf4c8b1f73f3ec3d5af" + "reference": "253dca476f70808a5aeed3a47cc2cc88c5cab915" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/annotations/zipball/fb0d71a7393298a7b232cbf4c8b1f73f3ec3d5af", - "reference": "fb0d71a7393298a7b232cbf4c8b1f73f3ec3d5af", + "url": "https://api.github.com/repos/doctrine/annotations/zipball/253dca476f70808a5aeed3a47cc2cc88c5cab915", + "reference": "253dca476f70808a5aeed3a47cc2cc88c5cab915", "shasum": "" }, "require": { @@ -446,11 +446,11 @@ }, "require-dev": { "doctrine/cache": "^1.11 || ^2.0", - "doctrine/coding-standard": "^9 || ^10", - "phpstan/phpstan": "~1.4.10 || ^1.8.0", + "doctrine/coding-standard": "^9 || ^12", + "phpstan/phpstan": "~1.4.10 || ^1.10.28", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "symfony/cache": "^4.4 || ^5.4 || ^6", - "vimeo/psalm": "^4.10" + "symfony/cache": "^4.4 || ^5.4 || ^6.4 || ^7", + "vimeo/psalm": "^4.30 || ^5.14" }, "suggest": { "php": "PHP 8.0 or higher comes with attributes, a native replacement for annotations" @@ -496,9 +496,9 @@ ], "support": { "issues": "https://github.com/doctrine/annotations/issues", - "source": "https://github.com/doctrine/annotations/tree/1.14.3" + "source": "https://github.com/doctrine/annotations/tree/1.14.4" }, - "time": "2023-02-01T09:20:38+00:00" + "time": "2024-09-05T10:15:52+00:00" }, { "name": "doctrine/cache", @@ -3311,16 +3311,16 @@ }, { "name": "nette/application", - "version": "v3.2.5", + "version": "v3.2.6", "source": { "type": "git", "url": "https://github.com/nette/application.git", - "reference": "1e868966c3de55a087e5ec938189ec34a1648b04" + "reference": "9c288cc45df467dc012504f4ad64791279720af8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/application/zipball/1e868966c3de55a087e5ec938189ec34a1648b04", - "reference": "1e868966c3de55a087e5ec938189ec34a1648b04", + "url": "https://api.github.com/repos/nette/application/zipball/9c288cc45df467dc012504f4ad64791279720af8", + "reference": "9c288cc45df467dc012504f4ad64791279720af8", "shasum": "" }, "require": { @@ -3328,10 +3328,10 @@ "nette/http": "^3.3", "nette/routing": "^3.1", "nette/utils": "^4.0", - "php": "8.1 - 8.3" + "php": "8.1 - 8.4" }, "conflict": { - "latte/latte": "<2.7.1 || >=3.0.0 <3.0.12 || >=3.1", + "latte/latte": "<2.7.1 || >=3.0.0 <3.0.18 || >=3.1", "nette/caching": "<3.2", "nette/di": "<3.2", "nette/forms": "<3.2", @@ -3340,7 +3340,7 @@ }, "require-dev": { "jetbrains/phpstorm-attributes": "dev-master", - "latte/latte": "^2.10.2 || ^3.0.12", + "latte/latte": "^2.10.2 || ^3.0.18", "mockery/mockery": "^2.0", "nette/di": "^3.2", "nette/forms": "^3.2", @@ -3397,9 +3397,9 @@ ], "support": { "issues": "https://github.com/nette/application/issues", - "source": "https://github.com/nette/application/tree/v3.2.5" + "source": "https://github.com/nette/application/tree/v3.2.6" }, - "time": "2024-05-13T09:10:31+00:00" + "time": "2024-09-10T10:08:04+00:00" }, { "name": "nette/bootstrap", @@ -4118,21 +4118,21 @@ }, { "name": "nette/php-generator", - "version": "v4.1.5", + "version": "v4.1.6", "source": { "type": "git", "url": "https://github.com/nette/php-generator.git", - "reference": "690b00d81d42d5633e4457c43ef9754573b6f9d6" + "reference": "c90961e782ae86e517fe5ed732eb2b512945565b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/php-generator/zipball/690b00d81d42d5633e4457c43ef9754573b6f9d6", - "reference": "690b00d81d42d5633e4457c43ef9754573b6f9d6", + "url": "https://api.github.com/repos/nette/php-generator/zipball/c90961e782ae86e517fe5ed732eb2b512945565b", + "reference": "c90961e782ae86e517fe5ed732eb2b512945565b", "shasum": "" }, "require": { "nette/utils": "^3.2.9 || ^4.0", - "php": "8.0 - 8.3" + "php": "8.0 - 8.4" }, "require-dev": { "jetbrains/phpstorm-attributes": "dev-master", @@ -4181,9 +4181,9 @@ ], "support": { "issues": "https://github.com/nette/php-generator/issues", - "source": "https://github.com/nette/php-generator/tree/v4.1.5" + "source": "https://github.com/nette/php-generator/tree/v4.1.6" }, - "time": "2024-05-12T17:31:02+00:00" + "time": "2024-09-10T09:31:55+00:00" }, { "name": "nette/robot-loader", @@ -5429,16 +5429,16 @@ }, { "name": "psr/log", - "version": "3.0.1", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "79dff0b268932c640297f5208d6298f71855c03e" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/79dff0b268932c640297f5208d6298f71855c03e", - "reference": "79dff0b268932c640297f5208d6298f71855c03e", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { @@ -5473,9 +5473,9 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/3.0.1" + "source": "https://github.com/php-fig/log/tree/3.0.2" }, - "time": "2024-08-21T13:31:24+00:00" + "time": "2024-09-11T13:17:53+00:00" }, { "name": "psr/simple-cache", @@ -5839,16 +5839,16 @@ }, { "name": "sebastian/comparator", - "version": "6.0.2", + "version": "6.1.0", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "450d8f237bd611c45b5acf0733ce43e6bb280f81" + "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/450d8f237bd611c45b5acf0733ce43e6bb280f81", - "reference": "450d8f237bd611c45b5acf0733ce43e6bb280f81", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa37b9e2ca618cb051d71b60120952ee8ca8b03d", + "reference": "fa37b9e2ca618cb051d71b60120952ee8ca8b03d", "shasum": "" }, "require": { @@ -5859,12 +5859,12 @@ "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.0-dev" + "dev-main": "6.1-dev" } }, "autoload": { @@ -5904,7 +5904,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/comparator/issues", "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.0.2" + "source": "https://github.com/sebastianbergmann/comparator/tree/6.1.0" }, "funding": [ { @@ -5912,7 +5912,7 @@ "type": "github" } ], - "time": "2024-08-12T06:07:25+00:00" + "time": "2024-09-11T15:42:56+00:00" }, { "name": "sebastian/diff", @@ -6188,16 +6188,16 @@ }, { "name": "symfony/cache", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "b61e464d7687bb7e8f677d5031c632bf3820df18" + "reference": "86e5296b10e4dec8c8441056ca606aedb8a3be0a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/b61e464d7687bb7e8f677d5031c632bf3820df18", - "reference": "b61e464d7687bb7e8f677d5031c632bf3820df18", + "url": "https://api.github.com/repos/symfony/cache/zipball/86e5296b10e4dec8c8441056ca606aedb8a3be0a", + "reference": "86e5296b10e4dec8c8441056ca606aedb8a3be0a", "shasum": "" }, "require": { @@ -6265,7 +6265,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.1.4" + "source": "https://github.com/symfony/cache/tree/v7.1.5" }, "funding": [ { @@ -6281,7 +6281,7 @@ "type": "tidelift" } ], - "time": "2024-08-12T09:59:40+00:00" + "time": "2024-09-17T09:16:35+00:00" }, { "name": "symfony/cache-contracts", @@ -6361,16 +6361,16 @@ }, { "name": "symfony/console", - "version": "v6.4.11", + "version": "v6.4.12", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "42686880adaacdad1835ee8fc2a9ec5b7bd63998" + "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/42686880adaacdad1835ee8fc2a9ec5b7bd63998", - "reference": "42686880adaacdad1835ee8fc2a9ec5b7bd63998", + "url": "https://api.github.com/repos/symfony/console/zipball/72d080eb9edf80e36c19be61f72c98ed8273b765", + "reference": "72d080eb9edf80e36c19be61f72c98ed8273b765", "shasum": "" }, "require": { @@ -6435,7 +6435,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.4.11" + "source": "https://github.com/symfony/console/tree/v6.4.12" }, "funding": [ { @@ -6451,7 +6451,7 @@ "type": "tidelift" } ], - "time": "2024-08-15T22:48:29+00:00" + "time": "2024-09-20T08:15:52+00:00" }, { "name": "symfony/deprecation-contracts", @@ -6520,22 +6520,86 @@ ], "time": "2024-04-18T09:32:20+00:00" }, + { + "name": "symfony/finder", + "version": "v7.1.4", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "d95bbf319f7d052082fb7af147e0f835a695e823" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/d95bbf319f7d052082fb7af147e0f835a695e823", + "reference": "d95bbf319f7d052082fb7af147e0f835a695e823", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.1.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-08-13T14:28:19+00:00" + }, { "name": "symfony/polyfill-ctype", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540" + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/0424dff1c58f028c451efff2045f5d92410bd540", - "reference": "0424dff1c58f028c451efff2045f5d92410bd540", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-ctype": "*" @@ -6581,7 +6645,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0" }, "funding": [ { @@ -6597,24 +6661,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a" + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/64647a7c30b2283f5d49b874d84a18fc22054b7a", - "reference": "64647a7c30b2283f5d49b874d84a18fc22054b7a", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" @@ -6659,7 +6723,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" }, "funding": [ { @@ -6675,24 +6739,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb" + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/a95281b0be0d9ab48050ebd988b967875cdb9fdb", - "reference": "a95281b0be0d9ab48050ebd988b967875cdb9fdb", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "suggest": { "ext-intl": "For best performance" @@ -6740,7 +6804,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" }, "funding": [ { @@ -6756,24 +6820,24 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", - "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "provide": { "ext-mbstring": "*" @@ -6820,7 +6884,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -6836,40 +6900,32 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php72", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "10112722600777e02d2745716b70c5db4ca70442" + "reference": "fa2ae56c44f03bed91a39bfc9822e31e7c5c38ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/10112722600777e02d2745716b70c5db4ca70442", - "reference": "10112722600777e02d2745716b70c5db4ca70442", + "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/fa2ae56c44f03bed91a39bfc9822e31e7c5c38ce", + "reference": "fa2ae56c44f03bed91a39bfc9822e31e7c5c38ce", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, - "type": "library", + "type": "metapackage", "extra": { "thanks": { "name": "symfony/polyfill", "url": "https://github.com/symfony/polyfill" } }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php72\\": "" - } - }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" @@ -6893,7 +6949,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php72/tree/v1.31.0" }, "funding": [ { @@ -6909,24 +6965,24 @@ "type": "tidelift" } ], - "time": "2024-06-19T12:30:46+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.30.0", + "version": "v1.31.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433" + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/77fa7995ac1b21ab60769b7323d600a991a90433", - "reference": "77fa7995ac1b21ab60769b7323d600a991a90433", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=7.2" }, "type": "library", "extra": { @@ -6973,7 +7029,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.30.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" }, "funding": [ { @@ -6989,7 +7045,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/process", @@ -7359,16 +7415,16 @@ }, { "name": "symfony/string", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6cd670a6d968eaeb1c77c2e76091c45c56bc367b" + "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6cd670a6d968eaeb1c77c2e76091c45c56bc367b", - "reference": "6cd670a6d968eaeb1c77c2e76091c45c56bc367b", + "url": "https://api.github.com/repos/symfony/string/zipball/d66f9c343fa894ec2037cc928381df90a7ad4306", + "reference": "d66f9c343fa894ec2037cc928381df90a7ad4306", "shasum": "" }, "require": { @@ -7426,7 +7482,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.1.4" + "source": "https://github.com/symfony/string/tree/v7.1.5" }, "funding": [ { @@ -7442,20 +7498,20 @@ "type": "tidelift" } ], - "time": "2024-08-12T09:59:40+00:00" + "time": "2024-09-20T08:28:38+00:00" }, { "name": "symfony/type-info", - "version": "v7.1.1", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/type-info.git", - "reference": "60b28eb733f1453287f1263ed305b96091e0d1dc" + "reference": "9f6094aa900d2c06bd61576a6f279d4ac441515f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/60b28eb733f1453287f1263ed305b96091e0d1dc", - "reference": "60b28eb733f1453287f1263ed305b96091e0d1dc", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f6094aa900d2c06bd61576a6f279d4ac441515f", + "reference": "9f6094aa900d2c06bd61576a6f279d4ac441515f", "shasum": "" }, "require": { @@ -7508,7 +7564,7 @@ "type" ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.1.1" + "source": "https://github.com/symfony/type-info/tree/v7.1.5" }, "funding": [ { @@ -7524,7 +7580,7 @@ "type": "tidelift" } ], - "time": "2024-05-31T14:59:31+00:00" + "time": "2024-09-19T21:48:23+00:00" }, { "name": "symfony/var-exporter", @@ -7604,16 +7660,16 @@ }, { "name": "symfony/yaml", - "version": "v7.1.4", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "92e080b851c1c655c786a2da77f188f2dccd0f4b" + "reference": "4e561c316e135e053bd758bf3b3eb291d9919de4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/92e080b851c1c655c786a2da77f188f2dccd0f4b", - "reference": "92e080b851c1c655c786a2da77f188f2dccd0f4b", + "url": "https://api.github.com/repos/symfony/yaml/zipball/4e561c316e135e053bd758bf3b3eb291d9919de4", + "reference": "4e561c316e135e053bd758bf3b3eb291d9919de4", "shasum": "" }, "require": { @@ -7655,7 +7711,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v7.1.4" + "source": "https://github.com/symfony/yaml/tree/v7.1.5" }, "funding": [ { @@ -7671,7 +7727,7 @@ "type": "tidelift" } ], - "time": "2024-08-12T09:59:40+00:00" + "time": "2024-09-17T12:49:58+00:00" }, { "name": "tracy/tracy", @@ -7747,6 +7803,87 @@ "source": "https://github.com/nette/tracy/tree/v2.10.8" }, "time": "2024-08-07T02:04:53+00:00" + }, + { + "name": "zircote/swagger-php", + "version": "4.10.6", + "source": { + "type": "git", + "url": "https://github.com/zircote/swagger-php.git", + "reference": "e462ff5269ea0ec91070edd5d51dc7215bdea3b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/e462ff5269ea0ec91070edd5d51dc7215bdea3b6", + "reference": "e462ff5269ea0ec91070edd5d51dc7215bdea3b6", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=7.2", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "symfony/deprecation-contracts": "^2 || ^3", + "symfony/finder": ">=2.2", + "symfony/yaml": ">=3.3" + }, + "require-dev": { + "composer/package-versions-deprecated": "^1.11", + "doctrine/annotations": "^1.7 || ^2.0", + "friendsofphp/php-cs-fixer": "^2.17 || ^3.47.1", + "phpstan/phpstan": "^1.6", + "phpunit/phpunit": ">=8", + "vimeo/psalm": "^4.23" + }, + "suggest": { + "doctrine/annotations": "^1.7 || ^2.0" + }, + "bin": [ + "bin/openapi" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenApi\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Robert Allen", + "email": "zircote@gmail.com" + }, + { + "name": "Bob Fanger", + "email": "bfanger@gmail.com", + "homepage": "https://bfanger.nl" + }, + { + "name": "Martin Rademacher", + "email": "mano@radebatz.net", + "homepage": "https://radebatz.net" + } + ], + "description": "swagger-php - Generate interactive documentation for your RESTful API using phpdoc annotations", + "homepage": "https://github.com/zircote/swagger-php/", + "keywords": [ + "api", + "json", + "rest", + "service discovery" + ], + "support": { + "issues": "https://github.com/zircote/swagger-php/issues", + "source": "https://github.com/zircote/swagger-php/tree/4.10.6" + }, + "time": "2024-07-26T03:04:43+00:00" } ], "packages-dev": [ @@ -8013,16 +8150,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.12.1", + "version": "1.12.5", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "d8ed7fffa66de1db0d2972267d8ed1d8fa0fe5a2" + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/d8ed7fffa66de1db0d2972267d8ed1d8fa0fe5a2", - "reference": "d8ed7fffa66de1db0d2972267d8ed1d8fa0fe5a2", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", "shasum": "" }, "require": { @@ -8067,7 +8204,7 @@ "type": "github" } ], - "time": "2024-09-03T19:55:22+00:00" + "time": "2024-09-26T12:45:22+00:00" }, { "name": "phpstan/phpstan-nette", diff --git a/generate-swagger b/generate-swagger new file mode 100644 index 000000000..834017705 --- /dev/null +++ b/generate-swagger @@ -0,0 +1,5 @@ +#!/bin/sh + +./bin/console swagger:annotate +./cleaner +./bin/console swagger:generate