From b1c367b4dd21b215d31987d351b20e8c451a0bab Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 1 Oct 2024 11:47:50 +0200 Subject: [PATCH 01/25] created minimal console command --- app/commands/GenerateSwagger.php | 640 ++----------------------------- app/config/config.neon | 1 + 2 files changed, 23 insertions(+), 618 deletions(-) diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index f75b6c457..35aa6caf1 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -2,626 +2,30 @@ 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 App\Helpers\Notifications\ReviewsEmailsSender; +use App\Model\Repository\AssignmentSolutions; +use App\Model\Entity\Group; +use App\Model\Entity\User; 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 DateTime; -// 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"]); -// } -// } +class GenerateSwagger extends Command +{ + protected static $defaultName = 'swagger:generate'; + + protected function configure() + { + $this->setName(self::$defaultName)->setDescription( + 'Generate a swagger specification file from existing code.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $output->writeln('TEST'); + return Command::SUCCESS; + } +} diff --git a/app/config/config.neon b/app/config/config.neon index fe0ef69b1..890191114 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -318,6 +318,7 @@ services: - App\Console\AsyncJobsUpkeep(%async.upkeep%) - App\Console\GeneralStatsNotification - App\Console\ExportDatabase + - App\Console\GenerateSwagger - App\Console\CleanupLocalizedTexts - App\Console\CleanupExerciseConfigs - App\Console\CleanupPipelineConfigs From 0ce4b0e4fa7081cbecc19ad7be0bb726917c00b2 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 3 Oct 2024 11:41:28 +0200 Subject: [PATCH 02/25] added WIP swagger:annotate which scans method annotations --- app/commands/GenerateSwagger.php | 7 +- app/commands/SwaggerAnnotator.php | 207 +++++++++++++++ app/config/config.neon | 1 + composer.json | 3 +- composer.lock | 403 ++++++++++++++++++++---------- 5 files changed, 486 insertions(+), 135 deletions(-) create mode 100644 app/commands/SwaggerAnnotator.php diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index 35aa6caf1..e580c6a45 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -25,7 +25,12 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { - $output->writeln('TEST'); + // $openapi = \OpenApi\Generator::scan([__DIR__ . '/../V1Module/presenters/OpenApiSpec.php']); + $openapi = \OpenApi\Generator::scan([__DIR__ . '/../']); + + header('Content-Type: application/x-yaml'); + echo $openapi->toYaml(); + return Command::SUCCESS; } } diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php new file mode 100644 index 000000000..bdde95f4a --- /dev/null +++ b/app/commands/SwaggerAnnotator.php @@ -0,0 +1,207 @@ +setName(self::$defaultName)->setDescription( + 'Annotate all methods with Swagger PHP annotations.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $r = new AnnotationHelper('App\V1Module\Presenters\UsersPresenter'); + $data = $r->extractMethodData('actionUpdateUiData'); + var_dump($data); + + return Command::SUCCESS; + } +} + +enum HttpMethods: string { + case GET = "@GET"; + case POST = "@POST"; + case PUT = "@PUT"; + case DELETE = "@DELETE"; +} + +class AnnotationData { + public HttpMethods $method; + public array $queryParams; + public array $bodyParams; + + public function __construct( + HttpMethods $method, + array $queryParams, + array $bodyParams + ) { + $this->method = $method; + $this->queryParams = $queryParams; + $this->bodyParams = $bodyParams; + } +} + +class AnnotationParameterData { + public string $dataType; + public string $name; + public string $description; + + public function __construct( + string $dataType, + string $name, + string $description + ) { + $this->dataType = $dataType; + $this->name = $name; + $this->description = $description; + } +} + +class AnnotationHelper { + private string $className; + private \ReflectionClass $class; + + /** + * Constructor + * @param string $className Name of the class. + */ + public function __construct( + string $className + ) { + $this->className = $className; + $this->class = new \ReflectionClass($this->className); + } + + public function getMethod(string $methodName): \ReflectionMethod { + return $this->class->getMethod($methodName); + } + + function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { + # get string values of backed enumeration + $cases = HttpMethods::cases(); + $methods = []; + foreach ($cases as $case) { + $methods[] = $case->value; + } + + # check if the annotations have a http method + foreach ($methods as $method) { + if (in_array($method, $annotations)) { + return HttpMethods::from($method); + } + } + + return null; + } + + function extractAnnotationQueryParams(array $annotations): array { + $queryParams = []; + 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)); + $descriptor = new AnnotationParameterData($type, $name, $description); + $queryParams[] = $descriptor; + } + } + return $queryParams; + } + + function extractBodyParams(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; + } + + function extractAnnotationBodyParams(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 = $this->extractBodyParams($tokens); + $descriptor = new AnnotationParameterData($values["validation"], + $values["name"], $values["description"]); + $bodyParams[] = $descriptor; + } + } + return $bodyParams; + } + + function getMethodAnnotations(string $methodName): array { + $annotations = $this->getMethod($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; + } + + public function extractMethodData($methodName): AnnotationData { + $methodAnnotations = $this->getMethodAnnotations($methodName); + $httpMethod = $this->extractAnnotationHttpMethod($methodAnnotations); + $queryParams = $this->extractAnnotationQueryParams($methodAnnotations); + $bodyParams = $this->extractAnnotationBodyParams($methodAnnotations); + $data = new AnnotationData($httpMethod, $queryParams, $bodyParams); + return $data; + } +} \ No newline at end of file diff --git a/app/config/config.neon b/app/config/config.neon index 890191114..3f9e704eb 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -319,6 +319,7 @@ services: - App\Console\GeneralStatsNotification - App\Console\ExportDatabase - App\Console\GenerateSwagger + - App\Console\SwaggerAnnotator - App\Console\CleanupLocalizedTexts - App\Console\CleanupExerciseConfigs - App\Console\CleanupPipelineConfigs 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 8b48bf733..f5d8855b4 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,20 +7045,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/process", - "version": "v7.1.3", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca" + "reference": "5c03ee6369281177f07f7c68252a280beccba847" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7f2f542c668ad6c313dc4a5e9c3321f733197eca", - "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca", + "url": "https://api.github.com/repos/symfony/process/zipball/5c03ee6369281177f07f7c68252a280beccba847", + "reference": "5c03ee6369281177f07f7c68252a280beccba847", "shasum": "" }, "require": { @@ -7034,7 +7090,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.1.3" + "source": "https://github.com/symfony/process/tree/v7.1.5" }, "funding": [ { @@ -7050,7 +7106,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:44:47+00:00" + "time": "2024-09-19T21:48:23+00:00" }, { "name": "symfony/property-access", @@ -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", From 4dcc5c1e0fef595fef06827bc9572dd57dda7a1a Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Sat, 5 Oct 2024 17:11:17 +0200 Subject: [PATCH 03/25] WIP swagger:annotate can now extract annotations from all routed methods --- app/commands/SwaggerAnnotator.php | 133 ++++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 35 deletions(-) diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index bdde95f4a..d9c4e0ebb 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -6,6 +6,8 @@ use App\Model\Repository\AssignmentSolutions; use App\Model\Entity\Group; use App\Model\Entity\User; +use App\V1Module\Router\MethodRoute; +use Nette\Routing\RouteList; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -26,12 +28,85 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $r = new AnnotationHelper('App\V1Module\Presenters\UsersPresenter'); - $data = $r->extractMethodData('actionUpdateUiData'); - var_dump($data); + $namespacePrefix = 'App\V1Module\Presenters\\'; + + $routes = $this->getRoutes(); + foreach ($routes as $route) { + $metadata = $this->extractMetadata($route); + $route = $this->extractRoute($route); + + $className = $namespacePrefix . $metadata['class']; + $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method']); + } return Command::SUCCESS; } + + 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; + } + + private function extractRoute($routeObj) { + $mask = self::getPropertyValue($routeObj, "mask"); + return $mask; + } + + 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), + ]; + } + + 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); + } } enum HttpMethods: string { @@ -58,14 +133,14 @@ public function __construct( } class AnnotationParameterData { - public string $dataType; + public string|null $dataType; public string $name; - public string $description; + public string|null $description; public function __construct( - string $dataType, + string|null $dataType, string $name, - string $description + string|null $description ) { $this->dataType = $dataType; $this->name = $name; @@ -74,25 +149,12 @@ public function __construct( } class AnnotationHelper { - private string $className; - private \ReflectionClass $class; - - /** - * Constructor - * @param string $className Name of the class. - */ - public function __construct( - string $className - ) { - $this->className = $className; - $this->class = new \ReflectionClass($this->className); + private static function getMethod(string $className, string $methodName): \ReflectionMethod { + $class = new \ReflectionClass($className); + return $class->getMethod($methodName); } - public function getMethod(string $methodName): \ReflectionMethod { - return $this->class->getMethod($methodName); - } - - function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { + private static function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { # get string values of backed enumeration $cases = HttpMethods::cases(); $methods = []; @@ -110,7 +172,7 @@ function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { return null; } - function extractAnnotationQueryParams(array $annotations): array { + private static function extractAnnotationQueryParams(array $annotations): array { $queryParams = []; foreach ($annotations as $annotation) { # assumed that all query parameters have a @param annotation @@ -128,7 +190,7 @@ function extractAnnotationQueryParams(array $annotations): array { return $queryParams; } - function extractBodyParams(array $expressions): array { + private static function extractBodyParams(array $expressions): array { $dict = []; #sample: [ name="uiData", validation="array|null" ] foreach ($expressions as $expression) { @@ -141,7 +203,7 @@ function extractBodyParams(array $expressions): array { return $dict; } - function extractAnnotationBodyParams(array $annotations): array { + private static function extractAnnotationBodyParams(array $annotations): array { $bodyParams = []; $prefix = "@Param"; foreach ($annotations as $annotation) { @@ -151,7 +213,7 @@ function extractAnnotationBodyParams(array $annotations): array { # remove '@Param(' from the start and ')' from the end $body = substr($annotation, strlen($prefix) + 1, -1); $tokens = explode(", ", $body); - $values = $this->extractBodyParams($tokens); + $values = self::extractBodyParams($tokens); $descriptor = new AnnotationParameterData($values["validation"], $values["name"], $values["description"]); $bodyParams[] = $descriptor; @@ -160,8 +222,8 @@ function extractAnnotationBodyParams(array $annotations): array { return $bodyParams; } - function getMethodAnnotations(string $methodName): array { - $annotations = $this->getMethod($methodName)->getDocComment(); + 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 @@ -196,11 +258,12 @@ function getMethodAnnotations(string $methodName): array { return $merged; } - public function extractMethodData($methodName): AnnotationData { - $methodAnnotations = $this->getMethodAnnotations($methodName); - $httpMethod = $this->extractAnnotationHttpMethod($methodAnnotations); - $queryParams = $this->extractAnnotationQueryParams($methodAnnotations); - $bodyParams = $this->extractAnnotationBodyParams($methodAnnotations); + public static function extractAnnotationData(string $className, string $methodName): AnnotationData { + $methodAnnotations = self::getMethodAnnotations($className, $methodName); + + $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); + $queryParams = self::extractAnnotationQueryParams($methodAnnotations); + $bodyParams = self::extractAnnotationBodyParams($methodAnnotations); $data = new AnnotationData($httpMethod, $queryParams, $bodyParams); return $data; } From 887da6afc4822e8a656ea61eea932e967eec1bd7 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Mon, 7 Oct 2024 14:04:30 +0200 Subject: [PATCH 04/25] swagger:annotate now generates a file and swagger:generate can now convert it to a Swagger specification; newly supports path and query parameters --- app/commands/GenerateSwagger.php | 4 +- app/commands/SwaggerAnnotator.php | 211 +++++++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 6 deletions(-) diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index e580c6a45..2c6ac5e61 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -25,8 +25,8 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { - // $openapi = \OpenApi\Generator::scan([__DIR__ . '/../V1Module/presenters/OpenApiSpec.php']); - $openapi = \OpenApi\Generator::scan([__DIR__ . '/../']); + $openapi = \OpenApi\Generator::scan([__DIR__ . '/../V1Module/presenters/annotations.php']); + // $openapi = \OpenApi\Generator::scan([__DIR__ . '/../']); header('Content-Type: application/x-yaml'); echo $openapi->toYaml(); diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index d9c4e0ebb..2d94a96f5 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -30,6 +30,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $namespacePrefix = 'App\V1Module\Presenters\\'; + $fileBuilder = new FileBuilder("app/V1Module/presenters/annotations.php"); + $fileBuilder->startClass("AnnotationController"); $routes = $this->getRoutes(); foreach ($routes as $route) { $metadata = $this->extractMetadata($route); @@ -37,7 +39,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $className = $namespacePrefix . $metadata['class']; $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method']); + + $fileBuilder->addAnnotatedMethod($metadata['method'], $annotationData->toSwaggerAnnotations($route)); } + $fileBuilder->endClass(); + return Command::SUCCESS; } @@ -73,7 +79,10 @@ function getRoutes(): array { private function extractRoute($routeObj) { $mask = self::getPropertyValue($routeObj, "mask"); - return $mask; + + # sample: replaces '/users/' with '/users/{id}' + $mask = str_replace(["<", ">"], ["{", "}"], $mask); + return "/" . $mask; } private function extractMetadata($routeObj) { @@ -109,6 +118,57 @@ private static function getPropertyValue($object, string $propertyName): mixed } } +class FileBuilder { + private $file; + private $methodEntries; + + public function __construct( + string $filename + ) { + $this->initFile($filename); + $this->methodEntries = 0; + } + + private function initFile(string $filename) { + $this->file = fopen($filename, "w"); + fwrite($this->file, "file, "namespace App\V1Module\Presenters;\n"); + fwrite($this->file, "use OpenApi\Annotations as OA;\n"); + } + + ///TODO: hardcoded info + private function createInfoAnnotation() { + $head = "@OA\\Info"; + $body = new ParenthesesBuilder(); + $body->addKeyValue("version", "1.0"); + $body->addKeyValue("title", "ReCodEx API"); + return $head . $body->toString(); + } + + private function writeAnnotationLineWithComments(string $annotationLine) { + fwrite($this->file, "/**\n"); + fwrite($this->file, "* {$annotationLine}\n"); + fwrite($this->file, "*/\n"); + } + + public function startClass(string $className) { + ///TODO: hardcoded + $this->writeAnnotationLineWithComments($this->createInfoAnnotation()); + fwrite($this->file, "class {$className} {\n"); + } + + public function endClass(){ + fwrite($this->file, "}\n"); + } + + public function addAnnotatedMethod(string $methodName, string $annotationLine) { + $this->writeAnnotationLineWithComments($annotationLine); + fwrite($this->file, "public function {$methodName}{$this->methodEntries}() {}\n"); + $this->methodEntries++; + } + +} + enum HttpMethods: string { case GET = "@GET"; case POST = "@POST"; @@ -117,19 +177,84 @@ enum HttpMethods: string { } class AnnotationData { - public HttpMethods $method; + public HttpMethods $httpMethod; + + # $queryParams contain path and query params. This is because they are extracted from + # annotations directly, and the annotations do not contain this information. public array $queryParams; public array $bodyParams; public function __construct( - HttpMethods $method, + HttpMethods $httpMethod, array $queryParams, array $bodyParams ) { - $this->method = $method; + $this->httpMethod = $httpMethod; $this->queryParams = $queryParams; $this->bodyParams = $bodyParams; } + + private function getHttpMethodAnnotation(): string { + # sample: converts '@PUT' to 'Put' + $httpMethodString = ucfirst(strtolower(substr($this->httpMethod->value, 1))); + return "@OA\\" . $httpMethodString; + } + + private 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]; + } + + public function toSwaggerAnnotations(string $route) { + $httpMethodAnnotation = $this->getHttpMethodAnnotation(); + $body = new ParenthesesBuilder(); + $body->addKeyValue("path", $route); + + $pathParamNames = $this->getRoutePathParamNames($route); + foreach ($this->queryParams as $queryParam) { + # find out where the parameter is located + $location = 'query'; + if (in_array($queryParam->name, $pathParamNames)) + $location = 'path'; + + $body->addValue($queryParam->toParameterAnnotation($location)); + } + + ///TODO: placeholder + $body->addValue('@OA\Response(response="200",description="The data")'); + return $httpMethodAnnotation . $body->toString(); + } +} + +class ParenthesesBuilder { + private array $tokens; + + public function __construct() { + $this->tokens = []; + } + + 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 + else if (is_bool($value)) + $valueString = ($value ? "true" : "false"); + + $assignment = "{$key}={$valueString}"; + return $this->addValue($assignment); + } + + public function addValue(string $value): ParenthesesBuilder { + $this->tokens[] = $value; + return $this; + } + + public function toString(): string { + return '(' . implode(',', $this->tokens) . ')'; + } } class AnnotationParameterData { @@ -137,6 +262,31 @@ class AnnotationParameterData { public string $name; public string|null $description; + private static $nullableSuffix = '|null'; + private static $typeMap = [ + 'bool' => 'boolean', + 'boolean' => 'boolean', + 'array' => 'array', + '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( string|null $dataType, string $name, @@ -146,6 +296,59 @@ public function __construct( $this->name = $name; $this->description = $description; } + + 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; + } + + private function generateSchemaAnnotation(): 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) + throw new \InvalidArgumentException("Error in SwaggerTypeConverter: Unknown typename: {$typename}"); + + $type = self::$typeMap[$typename]; + } + + $head = "@OA\\Schema"; + $body = new ParenthesesBuilder(); + $body->addKeyValue("type", $type); + + return $head . $body->toString(); + } + + /** + * Converts the object to a @OA\Parameter(...) annotation string + * @param string $parameterLocation Where the parameter resides. Can be 'path', 'query', 'header' or 'cookie'. + */ + public function toParameterAnnotation(string $parameterLocation): string { + $head = "@OA\\Parameter"; + $body = new ParenthesesBuilder(); + + $body->addKeyValue("name", $this->name); + $body->addKeyValue("in", $parameterLocation); + $body->addKeyValue("required", !$this->isDatatypeNullable()); + if ($this->description !== null) + $body->addKeyValue("description", $this->description); + + $body->addValue($this->generateSchemaAnnotation()); + + return $head . $body->toString(); + } } class AnnotationHelper { From 174abc38c1ab4541d1f9e525a660f7b664f823fb Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 8 Oct 2024 09:23:02 +0200 Subject: [PATCH 05/25] added support for POST json properties --- app/commands/SwaggerAnnotator.php | 59 ++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 2d94a96f5..7addc05d5 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -206,6 +206,22 @@ private function getRoutePathParamNames(string $route): array { return $out[1]; } + private function getBodyAnnotation(): string|null { + if (count($this->bodyParams) === 0) { + return null; + } + + ///TODO: only supports JSON + $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() . "))"; + } + public function toSwaggerAnnotations(string $route) { $httpMethodAnnotation = $this->getHttpMethodAnnotation(); $body = new ParenthesesBuilder(); @@ -221,6 +237,10 @@ public function toSwaggerAnnotations(string $route) { $body->addValue($queryParam->toParameterAnnotation($location)); } + $jsonProperties = $this->getBodyAnnotation(); + if ($jsonProperties !== null) + $body->addValue($jsonProperties); + ///TODO: placeholder $body->addValue('@OA\Response(response="200",description="The data")'); return $httpMethodAnnotation . $body->toString(); @@ -275,16 +295,16 @@ class AnnotationParameterData { 'numericint' => 'integer', 'timestamp' => 'integer', 'string' => 'string', - 'unicode' => ['string', 'unicode'], - 'email' => ['string', 'email'], - 'url' => ['string', 'url'], - 'uri' => ['string', 'uri'], + 'unicode' => 'string', + 'email' => 'string', + 'url' => 'string', + 'uri' => 'string', 'pattern' => null, - 'alnum' => ['string', 'alphanumeric'], - 'alpha' => ['string', 'alphabetic'], - 'digit' => ['string', 'numeric'], - 'lower' => ['string', 'lowercase'], - 'upper' => ['string', 'uppercase'] + 'alnum' => 'string', + 'alpha' => 'string', + 'digit' => 'string', + 'lower' => 'string', + 'upper' => 'string', ]; public function __construct( @@ -310,7 +330,7 @@ private function isDatatypeNullable(): bool { return false; } - private function generateSchemaAnnotation(): string { + private function getSwaggerType(): string { # if the type is not specified, default to a string $type = 'string'; $typename = $this->dataType; @@ -319,15 +339,20 @@ private function generateSchemaAnnotation(): string { $typename = substr($typename,0,-strlen(self::$nullableSuffix)); if (self::$typeMap[$typename] === null) - throw new \InvalidArgumentException("Error in SwaggerTypeConverter: Unknown typename: {$typename}"); + ///TODO: return the commented exception + return 'string'; + //throw new \InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); $type = self::$typeMap[$typename]; } + return $type; + } + private function generateSchemaAnnotation(): string { $head = "@OA\\Schema"; $body = new ParenthesesBuilder(); - $body->addKeyValue("type", $type); + $body->addKeyValue("type", $this->getSwaggerType()); return $head . $body->toString(); } @@ -349,6 +374,16 @@ public function toParameterAnnotation(string $parameterLocation): string { return $head . $body->toString(); } + + public function toPropertyAnnotation(): string { + $head = "@OA\\Property"; + $body = new ParenthesesBuilder(); + + ///TODO: handle nullability + $body->addKeyValue("property", $this->name); + $body->addKeyValue("type", $this->getSwaggerType()); + return $head . $body->toString(); + } } class AnnotationHelper { From c4c88db141eaa813643d9531c9475d87dd7da294 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 8 Oct 2024 10:02:31 +0200 Subject: [PATCH 06/25] parameters are now correctly located in path, query, or body --- app/commands/SwaggerAnnotator.php | 89 ++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 7addc05d5..9bf7c3822 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -38,7 +38,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $route = $this->extractRoute($route); $className = $namespacePrefix . $metadata['class']; - $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method']); + $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method'], $route); $fileBuilder->addAnnotatedMethod($metadata['method'], $annotationData->toSwaggerAnnotations($route)); } @@ -179,17 +179,18 @@ enum HttpMethods: string { class AnnotationData { public HttpMethods $httpMethod; - # $queryParams contain path and query params. This is because they are extracted from - # annotations directly, and the annotations do not contain this information. + public array $pathParams; public array $queryParams; public array $bodyParams; public function __construct( HttpMethods $httpMethod, + array $pathParams, array $queryParams, array $bodyParams ) { $this->httpMethod = $httpMethod; + $this->pathParams = $pathParams; $this->queryParams = $queryParams; $this->bodyParams = $bodyParams; } @@ -200,12 +201,6 @@ private function getHttpMethodAnnotation(): string { return "@OA\\" . $httpMethodString; } - private 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]; - } - private function getBodyAnnotation(): string|null { if (count($this->bodyParams) === 0) { return null; @@ -227,14 +222,11 @@ public function toSwaggerAnnotations(string $route) { $body = new ParenthesesBuilder(); $body->addKeyValue("path", $route); - $pathParamNames = $this->getRoutePathParamNames($route); + foreach ($this->pathParams as $pathParam) { + $body->addValue($pathParam->toParameterAnnotation()); + } foreach ($this->queryParams as $queryParam) { - # find out where the parameter is located - $location = 'query'; - if (in_array($queryParam->name, $pathParamNames)) - $location = 'path'; - - $body->addValue($queryParam->toParameterAnnotation($location)); + $body->addValue($queryParam->toParameterAnnotation()); } $jsonProperties = $this->getBodyAnnotation(); @@ -281,6 +273,7 @@ class AnnotationParameterData { public string|null $dataType; public string $name; public string|null $description; + public string $location; private static $nullableSuffix = '|null'; private static $typeMap = [ @@ -310,11 +303,13 @@ class AnnotationParameterData { public function __construct( string|null $dataType, string $name, - string|null $description + string|null $description, + string $location ) { $this->dataType = $dataType; $this->name = $name; $this->description = $description; + $this->location = $location; } private function isDatatypeNullable(): bool { @@ -360,12 +355,12 @@ private function generateSchemaAnnotation(): string { * Converts the object to a @OA\Parameter(...) annotation string * @param string $parameterLocation Where the parameter resides. Can be 'path', 'query', 'header' or 'cookie'. */ - public function toParameterAnnotation(string $parameterLocation): string { + public function toParameterAnnotation(): string { $head = "@OA\\Parameter"; $body = new ParenthesesBuilder(); $body->addKeyValue("name", $this->name); - $body->addKeyValue("in", $parameterLocation); + $body->addKeyValue("in", $this->location); $body->addKeyValue("required", !$this->isDatatypeNullable()); if ($this->description !== null) $body->addKeyValue("description", $this->description); @@ -410,8 +405,10 @@ private static function extractAnnotationHttpMethod(array $annotations): HttpMet return null; } - private static function extractAnnotationQueryParams(array $annotations): array { - $queryParams = []; + 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")) { @@ -421,16 +418,22 @@ private static function extractAnnotationQueryParams(array $annotations): array # assumed that all names start with $ $name = substr($tokens[2], 1); $description = implode(" ", array_slice($tokens,3)); - $descriptor = new AnnotationParameterData($type, $name, $description); - $queryParams[] = $descriptor; + + # 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 $queryParams; + return $params; } private static function extractBodyParams(array $expressions): array { $dict = []; - #sample: [ name="uiData", validation="array|null" ] + #sample: [ 'name="uiData"', 'validation="array|null"' ] foreach ($expressions as $expression) { $tokens = explode('="', $expression); $name = $tokens[0]; @@ -441,7 +444,7 @@ private static function extractBodyParams(array $expressions): array { return $dict; } - private static function extractAnnotationBodyParams(array $annotations): array { + private static function extractNetteAnnotationParams(array $annotations): array { $bodyParams = []; $prefix = "@Param"; foreach ($annotations as $annotation) { @@ -453,7 +456,7 @@ private static function extractAnnotationBodyParams(array $annotations): array { $tokens = explode(", ", $body); $values = self::extractBodyParams($tokens); $descriptor = new AnnotationParameterData($values["validation"], - $values["name"], $values["description"]); + $values["name"], $values["description"], $values["type"]); $bodyParams[] = $descriptor; } } @@ -496,13 +499,37 @@ private static function getMethodAnnotations(string $className, string $methodNa return $merged; } - public static function extractAnnotationData(string $className, string $methodName): AnnotationData { + 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]; + } + + public static function extractAnnotationData(string $className, string $methodName, string $route): AnnotationData { $methodAnnotations = self::getMethodAnnotations($className, $methodName); $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); - $queryParams = self::extractAnnotationQueryParams($methodAnnotations); - $bodyParams = self::extractAnnotationBodyParams($methodAnnotations); - $data = new AnnotationData($httpMethod, $queryParams, $bodyParams); + $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; + else if ($param->location === 'query') + $queryParams[] = $param; + else if ($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; } } \ No newline at end of file From 5c63992cee2ebe55fc96e3c7ff537ab592f70e17 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 8 Oct 2024 13:25:21 +0200 Subject: [PATCH 07/25] improved code comments --- app/commands/SwaggerAnnotator.php | 74 ++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 9bf7c3822..83fd94a08 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -18,6 +18,8 @@ class SwaggerAnnotator extends Command { protected static $defaultName = 'swagger:annotate'; + private static $presenterNamespace = 'App\V1Module\Presenters\\'; + private static $autogeneratedAnnotationFilePath = 'app/V1Module/presenters/annotations.php'; protected function configure(): void { @@ -28,26 +30,33 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $namespacePrefix = 'App\V1Module\Presenters\\'; + # create a temporary file containing transpiled annotations usable by the external library (Swagger-PHP) + $fileBuilder = new FileBuilder(self::$autogeneratedAnnotationFilePath); + $fileBuilder->startClass('__Autogenerated_Annotation_Controller__', '1.0', 'ReCodEx API'); - $fileBuilder = new FileBuilder("app/V1Module/presenters/annotations.php"); - $fileBuilder->startClass("AnnotationController"); + # get all routes of the api $routes = $this->getRoutes(); - foreach ($routes as $route) { - $metadata = $this->extractMetadata($route); - $route = $this->extractRoute($route); + 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']; - $className = $namespacePrefix . $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; } + /** + * Finds all route objects of the API + * @return array Returns an array of all found route objects. + */ function getRoutes(): array { $router = \App\V1Module\RouterFactory::createRouter(); @@ -77,7 +86,11 @@ function getRoutes(): array { return $routes; } - private function extractRoute($routeObj) { + /** + * 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}' @@ -85,6 +98,11 @@ private function extractRoute($routeObj) { 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"]; @@ -100,6 +118,13 @@ private function extractMetadata($routeObj) { ]; } + /** + * 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); @@ -118,6 +143,9 @@ private static function getPropertyValue($object, string $propertyName): mixed } } +/** + * Builder class that handles .php file creation. + */ class FileBuilder { private $file; private $methodEntries; @@ -132,16 +160,16 @@ public function __construct( private function initFile(string $filename) { $this->file = fopen($filename, "w"); fwrite($this->file, "file, "/// THIS FILE WAS AUTOGENERATED\n"); fwrite($this->file, "namespace App\V1Module\Presenters;\n"); fwrite($this->file, "use OpenApi\Annotations as OA;\n"); } - ///TODO: hardcoded info - private function createInfoAnnotation() { + private function createInfoAnnotation(string $version, string $title) { $head = "@OA\\Info"; $body = new ParenthesesBuilder(); - $body->addKeyValue("version", "1.0"); - $body->addKeyValue("title", "ReCodEx API"); + $body->addKeyValue("version", $version); + $body->addKeyValue("title", $title); return $head . $body->toString(); } @@ -151,9 +179,8 @@ private function writeAnnotationLineWithComments(string $annotationLine) { fwrite($this->file, "*/\n"); } - public function startClass(string $className) { - ///TODO: hardcoded - $this->writeAnnotationLineWithComments($this->createInfoAnnotation()); + public function startClass(string $className, string $version, string $title) { + $this->writeAnnotationLineWithComments($this->createInfoAnnotation($version, $title)); fwrite($this->file, "class {$className} {\n"); } @@ -217,6 +244,11 @@ private function getBodyAnnotation(): string|null { 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(); @@ -239,6 +271,9 @@ public function toSwaggerAnnotations(string $route) { } } +/** + * Builder class that can create strings of the schema: '(key1="value1", key2="value2", standalone1, standalone2, ...)' + */ class ParenthesesBuilder { private array $tokens; @@ -269,6 +304,9 @@ public function toString(): string { } } +/** + * Contains data of a single annotation parameter. + */ class AnnotationParameterData { public string|null $dataType; public string $name; @@ -353,7 +391,6 @@ private function generateSchemaAnnotation(): string { /** * Converts the object to a @OA\Parameter(...) annotation string - * @param string $parameterLocation Where the parameter resides. Can be 'path', 'query', 'header' or 'cookie'. */ public function toParameterAnnotation(): string { $head = "@OA\\Parameter"; @@ -381,6 +418,9 @@ public function toPropertyAnnotation(): string { } } +/** + * Parser that can parse the annotations of existing recodex endpoints + */ class AnnotationHelper { private static function getMethod(string $className, string $methodName): \ReflectionMethod { $class = new \ReflectionClass($className); From 018a0b030cffc258de45003230c414ba5a0d6c88 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 8 Oct 2024 14:49:51 +0200 Subject: [PATCH 08/25] added a script that generates the swagger documentation, the commands now delete the temp file --- .gitignore | 1 + app/commands/GenerateSwagger.php | 13 ++++++++----- app/commands/SwaggerAnnotator.php | 4 +++- generate-swagger | 5 +++++ 4 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 generate-swagger 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 2c6ac5e61..67d020651 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -25,12 +25,15 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { - $openapi = \OpenApi\Generator::scan([__DIR__ . '/../V1Module/presenters/annotations.php']); - // $openapi = \OpenApi\Generator::scan([__DIR__ . '/../']); + $path = __DIR__ . '/../V1Module/presenters/_autogenerated_annotations_temp.php'; + $openapi = \OpenApi\Generator::scan([$path]); - header('Content-Type: application/x-yaml'); - echo $openapi->toYaml(); + header('Content-Type: application/x-yaml'); + $output->writeln($openapi->toYaml()); - return Command::SUCCESS; + # delete the temp file + unlink($path); + + return Command::SUCCESS; } } diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 83fd94a08..2e53945d8 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -19,7 +19,7 @@ class SwaggerAnnotator extends Command { protected static $defaultName = 'swagger:annotate'; private static $presenterNamespace = 'App\V1Module\Presenters\\'; - private static $autogeneratedAnnotationFilePath = 'app/V1Module/presenters/annotations.php'; + private static $autogeneratedAnnotationFilePath = 'app/V1Module/presenters/_autogenerated_annotations_temp.php'; protected function configure(): void { @@ -186,6 +186,8 @@ public function startClass(string $className, string $version, string $title) { public function endClass(){ fwrite($this->file, "}\n"); + fflush($this->file); + fclose($this->file); } public function addAnnotatedMethod(string $methodName, string $annotationLine) { 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 From 69463dd24a34d5b2cc5d1d114baf1d5d675e15f0 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 1 Oct 2024 11:47:50 +0200 Subject: [PATCH 09/25] created minimal console command --- app/commands/GenerateSwagger.php | 640 ++----------------------------- app/config/config.neon | 1 + 2 files changed, 23 insertions(+), 618 deletions(-) diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index f75b6c457..35aa6caf1 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -2,626 +2,30 @@ 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 App\Helpers\Notifications\ReviewsEmailsSender; +use App\Model\Repository\AssignmentSolutions; +use App\Model\Entity\Group; +use App\Model\Entity\User; 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 DateTime; -// 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"]); -// } -// } +class GenerateSwagger extends Command +{ + protected static $defaultName = 'swagger:generate'; + + protected function configure() + { + $this->setName(self::$defaultName)->setDescription( + 'Generate a swagger specification file from existing code.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $output->writeln('TEST'); + return Command::SUCCESS; + } +} diff --git a/app/config/config.neon b/app/config/config.neon index fe0ef69b1..890191114 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -318,6 +318,7 @@ services: - App\Console\AsyncJobsUpkeep(%async.upkeep%) - App\Console\GeneralStatsNotification - App\Console\ExportDatabase + - App\Console\GenerateSwagger - App\Console\CleanupLocalizedTexts - App\Console\CleanupExerciseConfigs - App\Console\CleanupPipelineConfigs From 138e6184acf5331729386144aad490e5c42a98da Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 3 Oct 2024 11:41:28 +0200 Subject: [PATCH 10/25] added WIP swagger:annotate which scans method annotations --- app/commands/GenerateSwagger.php | 7 +- app/commands/SwaggerAnnotator.php | 207 +++++++++++++++ app/config/config.neon | 1 + composer.json | 3 +- composer.lock | 403 ++++++++++++++++++++---------- 5 files changed, 486 insertions(+), 135 deletions(-) create mode 100644 app/commands/SwaggerAnnotator.php diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index 35aa6caf1..e580c6a45 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -25,7 +25,12 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { - $output->writeln('TEST'); + // $openapi = \OpenApi\Generator::scan([__DIR__ . '/../V1Module/presenters/OpenApiSpec.php']); + $openapi = \OpenApi\Generator::scan([__DIR__ . '/../']); + + header('Content-Type: application/x-yaml'); + echo $openapi->toYaml(); + return Command::SUCCESS; } } diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php new file mode 100644 index 000000000..bdde95f4a --- /dev/null +++ b/app/commands/SwaggerAnnotator.php @@ -0,0 +1,207 @@ +setName(self::$defaultName)->setDescription( + 'Annotate all methods with Swagger PHP annotations.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $r = new AnnotationHelper('App\V1Module\Presenters\UsersPresenter'); + $data = $r->extractMethodData('actionUpdateUiData'); + var_dump($data); + + return Command::SUCCESS; + } +} + +enum HttpMethods: string { + case GET = "@GET"; + case POST = "@POST"; + case PUT = "@PUT"; + case DELETE = "@DELETE"; +} + +class AnnotationData { + public HttpMethods $method; + public array $queryParams; + public array $bodyParams; + + public function __construct( + HttpMethods $method, + array $queryParams, + array $bodyParams + ) { + $this->method = $method; + $this->queryParams = $queryParams; + $this->bodyParams = $bodyParams; + } +} + +class AnnotationParameterData { + public string $dataType; + public string $name; + public string $description; + + public function __construct( + string $dataType, + string $name, + string $description + ) { + $this->dataType = $dataType; + $this->name = $name; + $this->description = $description; + } +} + +class AnnotationHelper { + private string $className; + private \ReflectionClass $class; + + /** + * Constructor + * @param string $className Name of the class. + */ + public function __construct( + string $className + ) { + $this->className = $className; + $this->class = new \ReflectionClass($this->className); + } + + public function getMethod(string $methodName): \ReflectionMethod { + return $this->class->getMethod($methodName); + } + + function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { + # get string values of backed enumeration + $cases = HttpMethods::cases(); + $methods = []; + foreach ($cases as $case) { + $methods[] = $case->value; + } + + # check if the annotations have a http method + foreach ($methods as $method) { + if (in_array($method, $annotations)) { + return HttpMethods::from($method); + } + } + + return null; + } + + function extractAnnotationQueryParams(array $annotations): array { + $queryParams = []; + 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)); + $descriptor = new AnnotationParameterData($type, $name, $description); + $queryParams[] = $descriptor; + } + } + return $queryParams; + } + + function extractBodyParams(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; + } + + function extractAnnotationBodyParams(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 = $this->extractBodyParams($tokens); + $descriptor = new AnnotationParameterData($values["validation"], + $values["name"], $values["description"]); + $bodyParams[] = $descriptor; + } + } + return $bodyParams; + } + + function getMethodAnnotations(string $methodName): array { + $annotations = $this->getMethod($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; + } + + public function extractMethodData($methodName): AnnotationData { + $methodAnnotations = $this->getMethodAnnotations($methodName); + $httpMethod = $this->extractAnnotationHttpMethod($methodAnnotations); + $queryParams = $this->extractAnnotationQueryParams($methodAnnotations); + $bodyParams = $this->extractAnnotationBodyParams($methodAnnotations); + $data = new AnnotationData($httpMethod, $queryParams, $bodyParams); + return $data; + } +} \ No newline at end of file diff --git a/app/config/config.neon b/app/config/config.neon index 890191114..3f9e704eb 100644 --- a/app/config/config.neon +++ b/app/config/config.neon @@ -319,6 +319,7 @@ services: - App\Console\GeneralStatsNotification - App\Console\ExportDatabase - App\Console\GenerateSwagger + - App\Console\SwaggerAnnotator - App\Console\CleanupLocalizedTexts - App\Console\CleanupExerciseConfigs - App\Console\CleanupPipelineConfigs 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 8b48bf733..f5d8855b4 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,20 +7045,20 @@ "type": "tidelift" } ], - "time": "2024-05-31T15:07:36+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { "name": "symfony/process", - "version": "v7.1.3", + "version": "v7.1.5", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca" + "reference": "5c03ee6369281177f07f7c68252a280beccba847" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/7f2f542c668ad6c313dc4a5e9c3321f733197eca", - "reference": "7f2f542c668ad6c313dc4a5e9c3321f733197eca", + "url": "https://api.github.com/repos/symfony/process/zipball/5c03ee6369281177f07f7c68252a280beccba847", + "reference": "5c03ee6369281177f07f7c68252a280beccba847", "shasum": "" }, "require": { @@ -7034,7 +7090,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.1.3" + "source": "https://github.com/symfony/process/tree/v7.1.5" }, "funding": [ { @@ -7050,7 +7106,7 @@ "type": "tidelift" } ], - "time": "2024-07-26T12:44:47+00:00" + "time": "2024-09-19T21:48:23+00:00" }, { "name": "symfony/property-access", @@ -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", From 77638fefbbf9d1a19ecc1ce6be08b5baacc71a95 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Sat, 5 Oct 2024 17:11:17 +0200 Subject: [PATCH 11/25] WIP swagger:annotate can now extract annotations from all routed methods --- app/commands/SwaggerAnnotator.php | 133 ++++++++++++++++++++++-------- 1 file changed, 98 insertions(+), 35 deletions(-) diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index bdde95f4a..d9c4e0ebb 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -6,6 +6,8 @@ use App\Model\Repository\AssignmentSolutions; use App\Model\Entity\Group; use App\Model\Entity\User; +use App\V1Module\Router\MethodRoute; +use Nette\Routing\RouteList; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -26,12 +28,85 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $r = new AnnotationHelper('App\V1Module\Presenters\UsersPresenter'); - $data = $r->extractMethodData('actionUpdateUiData'); - var_dump($data); + $namespacePrefix = 'App\V1Module\Presenters\\'; + + $routes = $this->getRoutes(); + foreach ($routes as $route) { + $metadata = $this->extractMetadata($route); + $route = $this->extractRoute($route); + + $className = $namespacePrefix . $metadata['class']; + $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method']); + } return Command::SUCCESS; } + + 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; + } + + private function extractRoute($routeObj) { + $mask = self::getPropertyValue($routeObj, "mask"); + return $mask; + } + + 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), + ]; + } + + 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); + } } enum HttpMethods: string { @@ -58,14 +133,14 @@ public function __construct( } class AnnotationParameterData { - public string $dataType; + public string|null $dataType; public string $name; - public string $description; + public string|null $description; public function __construct( - string $dataType, + string|null $dataType, string $name, - string $description + string|null $description ) { $this->dataType = $dataType; $this->name = $name; @@ -74,25 +149,12 @@ public function __construct( } class AnnotationHelper { - private string $className; - private \ReflectionClass $class; - - /** - * Constructor - * @param string $className Name of the class. - */ - public function __construct( - string $className - ) { - $this->className = $className; - $this->class = new \ReflectionClass($this->className); + private static function getMethod(string $className, string $methodName): \ReflectionMethod { + $class = new \ReflectionClass($className); + return $class->getMethod($methodName); } - public function getMethod(string $methodName): \ReflectionMethod { - return $this->class->getMethod($methodName); - } - - function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { + private static function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { # get string values of backed enumeration $cases = HttpMethods::cases(); $methods = []; @@ -110,7 +172,7 @@ function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { return null; } - function extractAnnotationQueryParams(array $annotations): array { + private static function extractAnnotationQueryParams(array $annotations): array { $queryParams = []; foreach ($annotations as $annotation) { # assumed that all query parameters have a @param annotation @@ -128,7 +190,7 @@ function extractAnnotationQueryParams(array $annotations): array { return $queryParams; } - function extractBodyParams(array $expressions): array { + private static function extractBodyParams(array $expressions): array { $dict = []; #sample: [ name="uiData", validation="array|null" ] foreach ($expressions as $expression) { @@ -141,7 +203,7 @@ function extractBodyParams(array $expressions): array { return $dict; } - function extractAnnotationBodyParams(array $annotations): array { + private static function extractAnnotationBodyParams(array $annotations): array { $bodyParams = []; $prefix = "@Param"; foreach ($annotations as $annotation) { @@ -151,7 +213,7 @@ function extractAnnotationBodyParams(array $annotations): array { # remove '@Param(' from the start and ')' from the end $body = substr($annotation, strlen($prefix) + 1, -1); $tokens = explode(", ", $body); - $values = $this->extractBodyParams($tokens); + $values = self::extractBodyParams($tokens); $descriptor = new AnnotationParameterData($values["validation"], $values["name"], $values["description"]); $bodyParams[] = $descriptor; @@ -160,8 +222,8 @@ function extractAnnotationBodyParams(array $annotations): array { return $bodyParams; } - function getMethodAnnotations(string $methodName): array { - $annotations = $this->getMethod($methodName)->getDocComment(); + 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 @@ -196,11 +258,12 @@ function getMethodAnnotations(string $methodName): array { return $merged; } - public function extractMethodData($methodName): AnnotationData { - $methodAnnotations = $this->getMethodAnnotations($methodName); - $httpMethod = $this->extractAnnotationHttpMethod($methodAnnotations); - $queryParams = $this->extractAnnotationQueryParams($methodAnnotations); - $bodyParams = $this->extractAnnotationBodyParams($methodAnnotations); + public static function extractAnnotationData(string $className, string $methodName): AnnotationData { + $methodAnnotations = self::getMethodAnnotations($className, $methodName); + + $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); + $queryParams = self::extractAnnotationQueryParams($methodAnnotations); + $bodyParams = self::extractAnnotationBodyParams($methodAnnotations); $data = new AnnotationData($httpMethod, $queryParams, $bodyParams); return $data; } From bd295a581d9723d2c382e923736e28fd0ea6d507 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Mon, 7 Oct 2024 14:04:30 +0200 Subject: [PATCH 12/25] swagger:annotate now generates a file and swagger:generate can now convert it to a Swagger specification; newly supports path and query parameters --- app/commands/GenerateSwagger.php | 4 +- app/commands/SwaggerAnnotator.php | 211 +++++++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 6 deletions(-) diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index e580c6a45..2c6ac5e61 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -25,8 +25,8 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { - // $openapi = \OpenApi\Generator::scan([__DIR__ . '/../V1Module/presenters/OpenApiSpec.php']); - $openapi = \OpenApi\Generator::scan([__DIR__ . '/../']); + $openapi = \OpenApi\Generator::scan([__DIR__ . '/../V1Module/presenters/annotations.php']); + // $openapi = \OpenApi\Generator::scan([__DIR__ . '/../']); header('Content-Type: application/x-yaml'); echo $openapi->toYaml(); diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index d9c4e0ebb..2d94a96f5 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -30,6 +30,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $namespacePrefix = 'App\V1Module\Presenters\\'; + $fileBuilder = new FileBuilder("app/V1Module/presenters/annotations.php"); + $fileBuilder->startClass("AnnotationController"); $routes = $this->getRoutes(); foreach ($routes as $route) { $metadata = $this->extractMetadata($route); @@ -37,7 +39,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int $className = $namespacePrefix . $metadata['class']; $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method']); + + $fileBuilder->addAnnotatedMethod($metadata['method'], $annotationData->toSwaggerAnnotations($route)); } + $fileBuilder->endClass(); + return Command::SUCCESS; } @@ -73,7 +79,10 @@ function getRoutes(): array { private function extractRoute($routeObj) { $mask = self::getPropertyValue($routeObj, "mask"); - return $mask; + + # sample: replaces '/users/' with '/users/{id}' + $mask = str_replace(["<", ">"], ["{", "}"], $mask); + return "/" . $mask; } private function extractMetadata($routeObj) { @@ -109,6 +118,57 @@ private static function getPropertyValue($object, string $propertyName): mixed } } +class FileBuilder { + private $file; + private $methodEntries; + + public function __construct( + string $filename + ) { + $this->initFile($filename); + $this->methodEntries = 0; + } + + private function initFile(string $filename) { + $this->file = fopen($filename, "w"); + fwrite($this->file, "file, "namespace App\V1Module\Presenters;\n"); + fwrite($this->file, "use OpenApi\Annotations as OA;\n"); + } + + ///TODO: hardcoded info + private function createInfoAnnotation() { + $head = "@OA\\Info"; + $body = new ParenthesesBuilder(); + $body->addKeyValue("version", "1.0"); + $body->addKeyValue("title", "ReCodEx API"); + return $head . $body->toString(); + } + + private function writeAnnotationLineWithComments(string $annotationLine) { + fwrite($this->file, "/**\n"); + fwrite($this->file, "* {$annotationLine}\n"); + fwrite($this->file, "*/\n"); + } + + public function startClass(string $className) { + ///TODO: hardcoded + $this->writeAnnotationLineWithComments($this->createInfoAnnotation()); + fwrite($this->file, "class {$className} {\n"); + } + + public function endClass(){ + fwrite($this->file, "}\n"); + } + + public function addAnnotatedMethod(string $methodName, string $annotationLine) { + $this->writeAnnotationLineWithComments($annotationLine); + fwrite($this->file, "public function {$methodName}{$this->methodEntries}() {}\n"); + $this->methodEntries++; + } + +} + enum HttpMethods: string { case GET = "@GET"; case POST = "@POST"; @@ -117,19 +177,84 @@ enum HttpMethods: string { } class AnnotationData { - public HttpMethods $method; + public HttpMethods $httpMethod; + + # $queryParams contain path and query params. This is because they are extracted from + # annotations directly, and the annotations do not contain this information. public array $queryParams; public array $bodyParams; public function __construct( - HttpMethods $method, + HttpMethods $httpMethod, array $queryParams, array $bodyParams ) { - $this->method = $method; + $this->httpMethod = $httpMethod; $this->queryParams = $queryParams; $this->bodyParams = $bodyParams; } + + private function getHttpMethodAnnotation(): string { + # sample: converts '@PUT' to 'Put' + $httpMethodString = ucfirst(strtolower(substr($this->httpMethod->value, 1))); + return "@OA\\" . $httpMethodString; + } + + private 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]; + } + + public function toSwaggerAnnotations(string $route) { + $httpMethodAnnotation = $this->getHttpMethodAnnotation(); + $body = new ParenthesesBuilder(); + $body->addKeyValue("path", $route); + + $pathParamNames = $this->getRoutePathParamNames($route); + foreach ($this->queryParams as $queryParam) { + # find out where the parameter is located + $location = 'query'; + if (in_array($queryParam->name, $pathParamNames)) + $location = 'path'; + + $body->addValue($queryParam->toParameterAnnotation($location)); + } + + ///TODO: placeholder + $body->addValue('@OA\Response(response="200",description="The data")'); + return $httpMethodAnnotation . $body->toString(); + } +} + +class ParenthesesBuilder { + private array $tokens; + + public function __construct() { + $this->tokens = []; + } + + 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 + else if (is_bool($value)) + $valueString = ($value ? "true" : "false"); + + $assignment = "{$key}={$valueString}"; + return $this->addValue($assignment); + } + + public function addValue(string $value): ParenthesesBuilder { + $this->tokens[] = $value; + return $this; + } + + public function toString(): string { + return '(' . implode(',', $this->tokens) . ')'; + } } class AnnotationParameterData { @@ -137,6 +262,31 @@ class AnnotationParameterData { public string $name; public string|null $description; + private static $nullableSuffix = '|null'; + private static $typeMap = [ + 'bool' => 'boolean', + 'boolean' => 'boolean', + 'array' => 'array', + '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( string|null $dataType, string $name, @@ -146,6 +296,59 @@ public function __construct( $this->name = $name; $this->description = $description; } + + 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; + } + + private function generateSchemaAnnotation(): 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) + throw new \InvalidArgumentException("Error in SwaggerTypeConverter: Unknown typename: {$typename}"); + + $type = self::$typeMap[$typename]; + } + + $head = "@OA\\Schema"; + $body = new ParenthesesBuilder(); + $body->addKeyValue("type", $type); + + return $head . $body->toString(); + } + + /** + * Converts the object to a @OA\Parameter(...) annotation string + * @param string $parameterLocation Where the parameter resides. Can be 'path', 'query', 'header' or 'cookie'. + */ + public function toParameterAnnotation(string $parameterLocation): string { + $head = "@OA\\Parameter"; + $body = new ParenthesesBuilder(); + + $body->addKeyValue("name", $this->name); + $body->addKeyValue("in", $parameterLocation); + $body->addKeyValue("required", !$this->isDatatypeNullable()); + if ($this->description !== null) + $body->addKeyValue("description", $this->description); + + $body->addValue($this->generateSchemaAnnotation()); + + return $head . $body->toString(); + } } class AnnotationHelper { From a5086cfb3c91245e08a3d7860543238324046c11 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 8 Oct 2024 09:23:02 +0200 Subject: [PATCH 13/25] added support for POST json properties --- app/commands/SwaggerAnnotator.php | 59 ++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 2d94a96f5..7addc05d5 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -206,6 +206,22 @@ private function getRoutePathParamNames(string $route): array { return $out[1]; } + private function getBodyAnnotation(): string|null { + if (count($this->bodyParams) === 0) { + return null; + } + + ///TODO: only supports JSON + $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() . "))"; + } + public function toSwaggerAnnotations(string $route) { $httpMethodAnnotation = $this->getHttpMethodAnnotation(); $body = new ParenthesesBuilder(); @@ -221,6 +237,10 @@ public function toSwaggerAnnotations(string $route) { $body->addValue($queryParam->toParameterAnnotation($location)); } + $jsonProperties = $this->getBodyAnnotation(); + if ($jsonProperties !== null) + $body->addValue($jsonProperties); + ///TODO: placeholder $body->addValue('@OA\Response(response="200",description="The data")'); return $httpMethodAnnotation . $body->toString(); @@ -275,16 +295,16 @@ class AnnotationParameterData { 'numericint' => 'integer', 'timestamp' => 'integer', 'string' => 'string', - 'unicode' => ['string', 'unicode'], - 'email' => ['string', 'email'], - 'url' => ['string', 'url'], - 'uri' => ['string', 'uri'], + 'unicode' => 'string', + 'email' => 'string', + 'url' => 'string', + 'uri' => 'string', 'pattern' => null, - 'alnum' => ['string', 'alphanumeric'], - 'alpha' => ['string', 'alphabetic'], - 'digit' => ['string', 'numeric'], - 'lower' => ['string', 'lowercase'], - 'upper' => ['string', 'uppercase'] + 'alnum' => 'string', + 'alpha' => 'string', + 'digit' => 'string', + 'lower' => 'string', + 'upper' => 'string', ]; public function __construct( @@ -310,7 +330,7 @@ private function isDatatypeNullable(): bool { return false; } - private function generateSchemaAnnotation(): string { + private function getSwaggerType(): string { # if the type is not specified, default to a string $type = 'string'; $typename = $this->dataType; @@ -319,15 +339,20 @@ private function generateSchemaAnnotation(): string { $typename = substr($typename,0,-strlen(self::$nullableSuffix)); if (self::$typeMap[$typename] === null) - throw new \InvalidArgumentException("Error in SwaggerTypeConverter: Unknown typename: {$typename}"); + ///TODO: return the commented exception + return 'string'; + //throw new \InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); $type = self::$typeMap[$typename]; } + return $type; + } + private function generateSchemaAnnotation(): string { $head = "@OA\\Schema"; $body = new ParenthesesBuilder(); - $body->addKeyValue("type", $type); + $body->addKeyValue("type", $this->getSwaggerType()); return $head . $body->toString(); } @@ -349,6 +374,16 @@ public function toParameterAnnotation(string $parameterLocation): string { return $head . $body->toString(); } + + public function toPropertyAnnotation(): string { + $head = "@OA\\Property"; + $body = new ParenthesesBuilder(); + + ///TODO: handle nullability + $body->addKeyValue("property", $this->name); + $body->addKeyValue("type", $this->getSwaggerType()); + return $head . $body->toString(); + } } class AnnotationHelper { From 5398ab77e031c96461e7193e4794852a42d114ba Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 8 Oct 2024 10:02:31 +0200 Subject: [PATCH 14/25] parameters are now correctly located in path, query, or body --- app/commands/SwaggerAnnotator.php | 89 ++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 7addc05d5..9bf7c3822 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -38,7 +38,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $route = $this->extractRoute($route); $className = $namespacePrefix . $metadata['class']; - $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method']); + $annotationData = AnnotationHelper::extractAnnotationData($className, $metadata['method'], $route); $fileBuilder->addAnnotatedMethod($metadata['method'], $annotationData->toSwaggerAnnotations($route)); } @@ -179,17 +179,18 @@ enum HttpMethods: string { class AnnotationData { public HttpMethods $httpMethod; - # $queryParams contain path and query params. This is because they are extracted from - # annotations directly, and the annotations do not contain this information. + public array $pathParams; public array $queryParams; public array $bodyParams; public function __construct( HttpMethods $httpMethod, + array $pathParams, array $queryParams, array $bodyParams ) { $this->httpMethod = $httpMethod; + $this->pathParams = $pathParams; $this->queryParams = $queryParams; $this->bodyParams = $bodyParams; } @@ -200,12 +201,6 @@ private function getHttpMethodAnnotation(): string { return "@OA\\" . $httpMethodString; } - private 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]; - } - private function getBodyAnnotation(): string|null { if (count($this->bodyParams) === 0) { return null; @@ -227,14 +222,11 @@ public function toSwaggerAnnotations(string $route) { $body = new ParenthesesBuilder(); $body->addKeyValue("path", $route); - $pathParamNames = $this->getRoutePathParamNames($route); + foreach ($this->pathParams as $pathParam) { + $body->addValue($pathParam->toParameterAnnotation()); + } foreach ($this->queryParams as $queryParam) { - # find out where the parameter is located - $location = 'query'; - if (in_array($queryParam->name, $pathParamNames)) - $location = 'path'; - - $body->addValue($queryParam->toParameterAnnotation($location)); + $body->addValue($queryParam->toParameterAnnotation()); } $jsonProperties = $this->getBodyAnnotation(); @@ -281,6 +273,7 @@ class AnnotationParameterData { public string|null $dataType; public string $name; public string|null $description; + public string $location; private static $nullableSuffix = '|null'; private static $typeMap = [ @@ -310,11 +303,13 @@ class AnnotationParameterData { public function __construct( string|null $dataType, string $name, - string|null $description + string|null $description, + string $location ) { $this->dataType = $dataType; $this->name = $name; $this->description = $description; + $this->location = $location; } private function isDatatypeNullable(): bool { @@ -360,12 +355,12 @@ private function generateSchemaAnnotation(): string { * Converts the object to a @OA\Parameter(...) annotation string * @param string $parameterLocation Where the parameter resides. Can be 'path', 'query', 'header' or 'cookie'. */ - public function toParameterAnnotation(string $parameterLocation): string { + public function toParameterAnnotation(): string { $head = "@OA\\Parameter"; $body = new ParenthesesBuilder(); $body->addKeyValue("name", $this->name); - $body->addKeyValue("in", $parameterLocation); + $body->addKeyValue("in", $this->location); $body->addKeyValue("required", !$this->isDatatypeNullable()); if ($this->description !== null) $body->addKeyValue("description", $this->description); @@ -410,8 +405,10 @@ private static function extractAnnotationHttpMethod(array $annotations): HttpMet return null; } - private static function extractAnnotationQueryParams(array $annotations): array { - $queryParams = []; + 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")) { @@ -421,16 +418,22 @@ private static function extractAnnotationQueryParams(array $annotations): array # assumed that all names start with $ $name = substr($tokens[2], 1); $description = implode(" ", array_slice($tokens,3)); - $descriptor = new AnnotationParameterData($type, $name, $description); - $queryParams[] = $descriptor; + + # 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 $queryParams; + return $params; } private static function extractBodyParams(array $expressions): array { $dict = []; - #sample: [ name="uiData", validation="array|null" ] + #sample: [ 'name="uiData"', 'validation="array|null"' ] foreach ($expressions as $expression) { $tokens = explode('="', $expression); $name = $tokens[0]; @@ -441,7 +444,7 @@ private static function extractBodyParams(array $expressions): array { return $dict; } - private static function extractAnnotationBodyParams(array $annotations): array { + private static function extractNetteAnnotationParams(array $annotations): array { $bodyParams = []; $prefix = "@Param"; foreach ($annotations as $annotation) { @@ -453,7 +456,7 @@ private static function extractAnnotationBodyParams(array $annotations): array { $tokens = explode(", ", $body); $values = self::extractBodyParams($tokens); $descriptor = new AnnotationParameterData($values["validation"], - $values["name"], $values["description"]); + $values["name"], $values["description"], $values["type"]); $bodyParams[] = $descriptor; } } @@ -496,13 +499,37 @@ private static function getMethodAnnotations(string $className, string $methodNa return $merged; } - public static function extractAnnotationData(string $className, string $methodName): AnnotationData { + 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]; + } + + public static function extractAnnotationData(string $className, string $methodName, string $route): AnnotationData { $methodAnnotations = self::getMethodAnnotations($className, $methodName); $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); - $queryParams = self::extractAnnotationQueryParams($methodAnnotations); - $bodyParams = self::extractAnnotationBodyParams($methodAnnotations); - $data = new AnnotationData($httpMethod, $queryParams, $bodyParams); + $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; + else if ($param->location === 'query') + $queryParams[] = $param; + else if ($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; } } \ No newline at end of file From 01f07054895ec49fbe5d6a570687e8bef58ce9df Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 8 Oct 2024 13:25:21 +0200 Subject: [PATCH 15/25] improved code comments --- app/commands/SwaggerAnnotator.php | 74 ++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 9bf7c3822..83fd94a08 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -18,6 +18,8 @@ class SwaggerAnnotator extends Command { protected static $defaultName = 'swagger:annotate'; + private static $presenterNamespace = 'App\V1Module\Presenters\\'; + private static $autogeneratedAnnotationFilePath = 'app/V1Module/presenters/annotations.php'; protected function configure(): void { @@ -28,26 +30,33 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - $namespacePrefix = 'App\V1Module\Presenters\\'; + # create a temporary file containing transpiled annotations usable by the external library (Swagger-PHP) + $fileBuilder = new FileBuilder(self::$autogeneratedAnnotationFilePath); + $fileBuilder->startClass('__Autogenerated_Annotation_Controller__', '1.0', 'ReCodEx API'); - $fileBuilder = new FileBuilder("app/V1Module/presenters/annotations.php"); - $fileBuilder->startClass("AnnotationController"); + # get all routes of the api $routes = $this->getRoutes(); - foreach ($routes as $route) { - $metadata = $this->extractMetadata($route); - $route = $this->extractRoute($route); + 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']; - $className = $namespacePrefix . $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; } + /** + * Finds all route objects of the API + * @return array Returns an array of all found route objects. + */ function getRoutes(): array { $router = \App\V1Module\RouterFactory::createRouter(); @@ -77,7 +86,11 @@ function getRoutes(): array { return $routes; } - private function extractRoute($routeObj) { + /** + * 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}' @@ -85,6 +98,11 @@ private function extractRoute($routeObj) { 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"]; @@ -100,6 +118,13 @@ private function extractMetadata($routeObj) { ]; } + /** + * 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); @@ -118,6 +143,9 @@ private static function getPropertyValue($object, string $propertyName): mixed } } +/** + * Builder class that handles .php file creation. + */ class FileBuilder { private $file; private $methodEntries; @@ -132,16 +160,16 @@ public function __construct( private function initFile(string $filename) { $this->file = fopen($filename, "w"); fwrite($this->file, "file, "/// THIS FILE WAS AUTOGENERATED\n"); fwrite($this->file, "namespace App\V1Module\Presenters;\n"); fwrite($this->file, "use OpenApi\Annotations as OA;\n"); } - ///TODO: hardcoded info - private function createInfoAnnotation() { + private function createInfoAnnotation(string $version, string $title) { $head = "@OA\\Info"; $body = new ParenthesesBuilder(); - $body->addKeyValue("version", "1.0"); - $body->addKeyValue("title", "ReCodEx API"); + $body->addKeyValue("version", $version); + $body->addKeyValue("title", $title); return $head . $body->toString(); } @@ -151,9 +179,8 @@ private function writeAnnotationLineWithComments(string $annotationLine) { fwrite($this->file, "*/\n"); } - public function startClass(string $className) { - ///TODO: hardcoded - $this->writeAnnotationLineWithComments($this->createInfoAnnotation()); + public function startClass(string $className, string $version, string $title) { + $this->writeAnnotationLineWithComments($this->createInfoAnnotation($version, $title)); fwrite($this->file, "class {$className} {\n"); } @@ -217,6 +244,11 @@ private function getBodyAnnotation(): string|null { 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(); @@ -239,6 +271,9 @@ public function toSwaggerAnnotations(string $route) { } } +/** + * Builder class that can create strings of the schema: '(key1="value1", key2="value2", standalone1, standalone2, ...)' + */ class ParenthesesBuilder { private array $tokens; @@ -269,6 +304,9 @@ public function toString(): string { } } +/** + * Contains data of a single annotation parameter. + */ class AnnotationParameterData { public string|null $dataType; public string $name; @@ -353,7 +391,6 @@ private function generateSchemaAnnotation(): string { /** * Converts the object to a @OA\Parameter(...) annotation string - * @param string $parameterLocation Where the parameter resides. Can be 'path', 'query', 'header' or 'cookie'. */ public function toParameterAnnotation(): string { $head = "@OA\\Parameter"; @@ -381,6 +418,9 @@ public function toPropertyAnnotation(): string { } } +/** + * Parser that can parse the annotations of existing recodex endpoints + */ class AnnotationHelper { private static function getMethod(string $className, string $methodName): \ReflectionMethod { $class = new \ReflectionClass($className); From f7c8ac9c4c3b9b3e12abf426c00f82b6d217c989 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 8 Oct 2024 14:49:51 +0200 Subject: [PATCH 16/25] added a script that generates the swagger documentation, the commands now delete the temp file --- .gitignore | 1 + app/commands/GenerateSwagger.php | 13 ++++++++----- app/commands/SwaggerAnnotator.php | 4 +++- generate-swagger | 5 +++++ 4 files changed, 17 insertions(+), 6 deletions(-) create mode 100644 generate-swagger 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 2c6ac5e61..67d020651 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -25,12 +25,15 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { - $openapi = \OpenApi\Generator::scan([__DIR__ . '/../V1Module/presenters/annotations.php']); - // $openapi = \OpenApi\Generator::scan([__DIR__ . '/../']); + $path = __DIR__ . '/../V1Module/presenters/_autogenerated_annotations_temp.php'; + $openapi = \OpenApi\Generator::scan([$path]); - header('Content-Type: application/x-yaml'); - echo $openapi->toYaml(); + header('Content-Type: application/x-yaml'); + $output->writeln($openapi->toYaml()); - return Command::SUCCESS; + # delete the temp file + unlink($path); + + return Command::SUCCESS; } } diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 83fd94a08..2e53945d8 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -19,7 +19,7 @@ class SwaggerAnnotator extends Command { protected static $defaultName = 'swagger:annotate'; private static $presenterNamespace = 'App\V1Module\Presenters\\'; - private static $autogeneratedAnnotationFilePath = 'app/V1Module/presenters/annotations.php'; + private static $autogeneratedAnnotationFilePath = 'app/V1Module/presenters/_autogenerated_annotations_temp.php'; protected function configure(): void { @@ -186,6 +186,8 @@ public function startClass(string $className, string $version, string $title) { public function endClass(){ fwrite($this->file, "}\n"); + fflush($this->file); + fclose($this->file); } public function addAnnotatedMethod(string $methodName, string $annotationLine) { 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 From 834b01b39c0daf6ce547ef5cb5d894e918123a23 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 14 Nov 2024 12:52:17 +0100 Subject: [PATCH 17/25] moved the annotation helper classes to helpers/Swagger --- app/commands/GenerateSwagger.php | 1 - app/commands/SwaggerAnnotator.php | 442 +------------------ app/helpers/Swagger/AnnotationHelper.php | 519 +++++++++++++++++++++++ 3 files changed, 521 insertions(+), 441 deletions(-) create mode 100644 app/helpers/Swagger/AnnotationHelper.php diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index 67d020651..bdff7df2f 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -28,7 +28,6 @@ protected function execute(InputInterface $input, OutputInterface $output) $path = __DIR__ . '/../V1Module/presenters/_autogenerated_annotations_temp.php'; $openapi = \OpenApi\Generator::scan([$path]); - header('Content-Type: application/x-yaml'); $output->writeln($openapi->toYaml()); # delete the temp file diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 2e53945d8..d8f821284 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -2,18 +2,13 @@ namespace App\Console; -use App\Helpers\Notifications\ReviewsEmailsSender; -use App\Model\Repository\AssignmentSolutions; -use App\Model\Entity\Group; -use App\Model\Entity\User; +use App\Helpers\Swagger\FileBuilder; +use App\Helpers\Swagger\AnnotationHelper; use App\V1Module\Router\MethodRoute; use Nette\Routing\RouteList; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Doctrine\Common\Annotations\AnnotationReader; -use DateTime; class SwaggerAnnotator extends Command { @@ -142,436 +137,3 @@ private static function getPropertyValue($object, string $propertyName): mixed return $property->getValue($object); } } - -/** - * Builder class that handles .php file creation. - */ -class FileBuilder { - private $file; - private $methodEntries; - - public function __construct( - string $filename - ) { - $this->initFile($filename); - $this->methodEntries = 0; - } - - private function initFile(string $filename) { - $this->file = fopen($filename, "w"); - fwrite($this->file, "file, "/// THIS FILE WAS AUTOGENERATED\n"); - fwrite($this->file, "namespace App\V1Module\Presenters;\n"); - fwrite($this->file, "use OpenApi\Annotations as OA;\n"); - } - - 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) { - fwrite($this->file, "/**\n"); - fwrite($this->file, "* {$annotationLine}\n"); - fwrite($this->file, "*/\n"); - } - - public function startClass(string $className, string $version, string $title) { - $this->writeAnnotationLineWithComments($this->createInfoAnnotation($version, $title)); - fwrite($this->file, "class {$className} {\n"); - } - - public function endClass(){ - fwrite($this->file, "}\n"); - fflush($this->file); - fclose($this->file); - } - - public function addAnnotatedMethod(string $methodName, string $annotationLine) { - $this->writeAnnotationLineWithComments($annotationLine); - fwrite($this->file, "public function {$methodName}{$this->methodEntries}() {}\n"); - $this->methodEntries++; - } - -} - -enum HttpMethods: string { - case GET = "@GET"; - case POST = "@POST"; - case PUT = "@PUT"; - case DELETE = "@DELETE"; -} - -class AnnotationData { - public HttpMethods $httpMethod; - - public array $pathParams; - public array $queryParams; - public array $bodyParams; - - public function __construct( - HttpMethods $httpMethod, - array $pathParams, - array $queryParams, - array $bodyParams - ) { - $this->httpMethod = $httpMethod; - $this->pathParams = $pathParams; - $this->queryParams = $queryParams; - $this->bodyParams = $bodyParams; - } - - private function getHttpMethodAnnotation(): string { - # sample: converts '@PUT' to 'Put' - $httpMethodString = ucfirst(strtolower(substr($this->httpMethod->value, 1))); - return "@OA\\" . $httpMethodString; - } - - private function getBodyAnnotation(): string|null { - if (count($this->bodyParams) === 0) { - return null; - } - - ///TODO: only supports JSON - $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: placeholder - $body->addValue('@OA\Response(response="200",description="The data")'); - return $httpMethodAnnotation . $body->toString(); - } -} - -/** - * Builder class that can create strings of the schema: '(key1="value1", key2="value2", standalone1, standalone2, ...)' - */ -class ParenthesesBuilder { - private array $tokens; - - public function __construct() { - $this->tokens = []; - } - - 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 - else if (is_bool($value)) - $valueString = ($value ? "true" : "false"); - - $assignment = "{$key}={$valueString}"; - return $this->addValue($assignment); - } - - public function addValue(string $value): ParenthesesBuilder { - $this->tokens[] = $value; - return $this; - } - - public function toString(): string { - return '(' . implode(',', $this->tokens) . ')'; - } -} - -/** - * Contains data of a single annotation parameter. - */ -class AnnotationParameterData { - public string|null $dataType; - public string $name; - public string|null $description; - public string $location; - - private static $nullableSuffix = '|null'; - private static $typeMap = [ - 'bool' => '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; - } - - 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 - return 'string'; - //throw new \InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); - - $type = self::$typeMap[$typename]; - } - return $type; - } - - 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(); - } - - public function toPropertyAnnotation(): string { - $head = "@OA\\Property"; - $body = new ParenthesesBuilder(); - - ///TODO: handle nullability - $body->addKeyValue("property", $this->name); - $body->addKeyValue("type", $this->getSwaggerType()); - return $head . $body->toString(); - } -} - -/** - * Parser that can parse the annotations of existing recodex endpoints - */ -class AnnotationHelper { - private static function getMethod(string $className, string $methodName): \ReflectionMethod { - $class = new \ReflectionClass($className); - return $class->getMethod($methodName); - } - - private static function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { - # get string values of backed enumeration - $cases = HttpMethods::cases(); - $methods = []; - foreach ($cases as $case) { - $methods[] = $case->value; - } - - # check if the annotations have a http method - foreach ($methods as $method) { - if (in_array($method, $annotations)) { - return HttpMethods::from($method); - } - } - - return null; - } - - 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; - } - - private static function extractBodyParams(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; - } - - 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::extractBodyParams($tokens); - $descriptor = new AnnotationParameterData($values["validation"], - $values["name"], $values["description"], $values["type"]); - $bodyParams[] = $descriptor; - } - } - return $bodyParams; - } - - 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; - } - - 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]; - } - - 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; - else if ($param->location === 'query') - $queryParams[] = $param; - else if ($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; - } -} \ No newline at end of file diff --git a/app/helpers/Swagger/AnnotationHelper.php b/app/helpers/Swagger/AnnotationHelper.php new file mode 100644 index 000000000..9a01b17f8 --- /dev/null +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -0,0 +1,519 @@ +initFile($filename); + $this->methodEntries = 0; + } + + private function initFile(string $filename) { + $this->file = fopen($filename, "w"); + fwrite($this->file, "file, "/// THIS FILE WAS AUTOGENERATED\n"); + fwrite($this->file, "namespace App\V1Module\Presenters;\n"); + fwrite($this->file, "use OpenApi\Annotations as OA;\n"); + } + + 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) { + fwrite($this->file, "/**\n"); + fwrite($this->file, "* {$annotationLine}\n"); + fwrite($this->file, "*/\n"); + } + + public function startClass(string $className, string $version, string $title) { + $this->writeAnnotationLineWithComments($this->createInfoAnnotation($version, $title)); + fwrite($this->file, "class {$className} {\n"); + } + + public function endClass(){ + fwrite($this->file, "}\n"); + fflush($this->file); + fclose($this->file); + } + + public function addAnnotatedMethod(string $methodName, string $annotationLine) { + $this->writeAnnotationLineWithComments($annotationLine); + fwrite($this->file, "public function {$methodName}{$this->methodEntries}() {}\n"); + $this->methodEntries++; + } +} + +/** + * Builder class that can create strings of the schema: '(key1="value1", key2="value2", standalone1, standalone2, ...)' + */ +class ParenthesesBuilder { + private array $tokens; + + public function __construct() { + $this->tokens = []; + } + + 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 + else if (is_bool($value)) + $valueString = ($value ? "true" : "false"); + + $assignment = "{$key}={$valueString}"; + return $this->addValue($assignment); + } + + public function addValue(string $value): ParenthesesBuilder { + $this->tokens[] = $value; + return $this; + } + + public function toString(): string { + return '(' . implode(',', $this->tokens) . ')'; + } +} + +class AnnotationData { + public HttpMethods $httpMethod; + + public array $pathParams; + public array $queryParams; + public array $bodyParams; + + public function __construct( + HttpMethods $httpMethod, + array $pathParams, + array $queryParams, + array $bodyParams + ) { + $this->httpMethod = $httpMethod; + $this->pathParams = $pathParams; + $this->queryParams = $queryParams; + $this->bodyParams = $bodyParams; + } + + private function getHttpMethodAnnotation(): string { + # sample: converts '@PUT' to 'Put' + $httpMethodString = ucfirst(strtolower(substr($this->httpMethod->value, 1))); + return "@OA\\" . $httpMethodString; + } + + private function getBodyAnnotation(): string|null { + if (count($this->bodyParams) === 0) { + return null; + } + + ///TODO: only supports JSON + $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: placeholder + $body->addValue('@OA\Response(response="200",description="The data")'); + return $httpMethodAnnotation . $body->toString(); + } +} + + +/** + * Contains data of a single annotation parameter. + */ +class AnnotationParameterData { + public string|null $dataType; + public string $name; + public string|null $description; + public string $location; + + private static $nullableSuffix = '|null'; + private static $typeMap = [ + 'bool' => '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; + } + + 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 + return 'string'; + //throw new \InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); + + $type = self::$typeMap[$typename]; + } + return $type; + } + + 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(); + } + + public function toPropertyAnnotation(): string { + $head = "@OA\\Property"; + $body = new ParenthesesBuilder(); + + ///TODO: handle nullability + $body->addKeyValue("property", $this->name); + $body->addKeyValue("type", $this->getSwaggerType()); + return $head . $body->toString(); + } +} + + +/** + * Parser that can parse the annotations of existing recodex endpoints + */ +class AnnotationHelper { + private static function getMethod(string $className, string $methodName): \ReflectionMethod { + $class = new \ReflectionClass($className); + return $class->getMethod($methodName); + } + + private static function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { + # get string values of backed enumeration + $cases = HttpMethods::cases(); + $methods = []; + foreach ($cases as $case) { + $methods[] = $case->value; + } + + # check if the annotations have a http method + foreach ($methods as $method) { + if (in_array($method, $annotations)) { + return HttpMethods::from($method); + } + } + + return null; + } + + 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; + } + + private static function extractBodyParams(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; + } + + 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::extractBodyParams($tokens); + $descriptor = new AnnotationParameterData($values["validation"], + $values["name"], $values["description"], $values["type"]); + $bodyParams[] = $descriptor; + } + } + return $bodyParams; + } + + 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; + } + + 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]; + } + + 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; + else if ($param->location === 'query') + $queryParams[] = $param; + else if ($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; + } + + private static function filterAnnotations(array $annotations, string $type) { + $rows = []; + foreach ($annotations as $annotation) { + if (str_starts_with($annotation, $type)) { + $rows[] = $annotation; + } + } + return $rows; + } + + private static function extractFormatData(array $annotations): array { + $formats = []; + $filtered = self::filterAnnotations($annotations, "@format_def"); + foreach ($filtered as $annotation) { + # sample: @format user_info { "name":"format:name", "points":"format:int", "comments":"format:string[]" } + $tokens = explode(" ", $annotation); + $name = $tokens[1]; + + $jsonStart = strpos($annotation, "{"); + $json = substr($annotation, $jsonStart); + $format = json_decode($json); + + $formats[$name] = $format; + } + return $formats; + } + + private static function extractMethodFormats(string $className, string $methodName): array { + $annotations = self::getMethodAnnotations($className, $methodName); + return self::extractFormatData($annotations); + } + + public static function extractClassFormats(string $className): array { + $methods = get_class_methods($className); + $formatDicts = []; + foreach ($methods as $method) { + $formatDicts[] = self::extractMethodFormats($className, $method); + } + + return array_merge(...$formatDicts); + } + + public static function extractMethodCheckedParams(string $className, string $methodName): array { + $annotations = self::getMethodAnnotations($className, $methodName); + $filtered = self::filterAnnotations($annotations, "@checked_param"); + + $paramMap = []; + foreach ($filtered as $annotation) { + // sample: @checked_param format:group group + $tokens = explode(" ", $annotation); + $format = $tokens[1]; + $name = $tokens[2]; + $paramMap[$name] = $format; + } + + return $paramMap; + } + + public static function extractClassFormat(string $className) { + $class = new \ReflectionClass($className); + $fields = get_class_vars($className); + foreach ($fields as $fieldName=>$value) { + $field = $class->getProperty($fieldName); + $fieldType = $field->getType()->getName(); + var_dump($fieldType); + } + } +} From aeeb298d36615a61499246c626428842461adcab Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 5 Dec 2024 19:30:22 +0100 Subject: [PATCH 18/25] made the swagger generation code PSR12 compliant --- app/commands/GenerateSwagger.php | 18 +- app/commands/SwaggerAnnotator.php | 83 ++-- app/helpers/Swagger/AnnotationData.php | 79 ++++ app/helpers/Swagger/AnnotationHelper.php | 422 ++++-------------- .../Swagger/AnnotationParameterData.php | 128 ++++++ app/helpers/Swagger/HttpMethods.php | 13 + app/helpers/Swagger/ParenthesesBuilder.php | 53 +++ .../Swagger/TempAnnotationFileBuilder.php | 76 ++++ 8 files changed, 485 insertions(+), 387 deletions(-) create mode 100644 app/helpers/Swagger/AnnotationData.php create mode 100644 app/helpers/Swagger/AnnotationParameterData.php create mode 100644 app/helpers/Swagger/HttpMethods.php create mode 100644 app/helpers/Swagger/ParenthesesBuilder.php create mode 100644 app/helpers/Swagger/TempAnnotationFileBuilder.php diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index bdff7df2f..f52288920 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -2,15 +2,10 @@ namespace App\Console; -use App\Helpers\Notifications\ReviewsEmailsSender; -use App\Model\Repository\AssignmentSolutions; -use App\Model\Entity\Group; -use App\Model\Entity\User; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use DateTime; +use \OpenApi\Generator; class GenerateSwagger extends Command { @@ -26,11 +21,18 @@ protected function configure() protected function execute(InputInterface $input, OutputInterface $output) { $path = __DIR__ . '/../V1Module/presenters/_autogenerated_annotations_temp.php'; - $openapi = \OpenApi\Generator::scan([$path]); + + // 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 + // delete the temp file unlink($path); return Command::SUCCESS; diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index d8f821284..d04dfcdec 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -2,13 +2,16 @@ namespace App\Console; -use App\Helpers\Swagger\FileBuilder; +use App\Helpers\Swagger\TempAnnotationFileBuilder; use App\Helpers\Swagger\AnnotationHelper; use App\V1Module\Router\MethodRoute; use Nette\Routing\RouteList; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +use Exception; +use ReflectionException; +use ReflectionClass; class SwaggerAnnotator extends Command { @@ -25,37 +28,44 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { - # create a temporary file containing transpiled annotations usable by the external library (Swagger-PHP) - $fileBuilder = new FileBuilder(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(); + 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::SUCCESS; + return Command::FAILURE; + } } /** * Finds all route objects of the API * @return array Returns an array of all found route objects. */ - function getRoutes(): array { + private function getRoutes(): array + { $router = \App\V1Module\RouterFactory::createRouter(); - # find all route object using a queue + // find all route object using a queue $queue = [$router]; $routes = []; while (count($queue) != 0) { @@ -63,14 +73,14 @@ function getRoutes(): array { if ($cursor instanceof RouteList) { foreach ($cursor->getRouters() as $item) { - # lists contain routes or nested lists + // 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) + } 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"); } @@ -85,10 +95,11 @@ function getRoutes(): array { * Extracts the route string from a route object. Replaces '<..>' in the route with '{...}'. * @param mixed $routeObj */ - private function extractRoute($routeObj): string { + private function extractRoute($routeObj): string + { $mask = self::getPropertyValue($routeObj, "mask"); - # sample: replaces '/users/' with '/users/{id}' + // sample: replaces '/users/' with '/users/{id}' $mask = str_replace(["<", ">"], ["{", "}"], $mask); return "/" . $mask; } @@ -98,14 +109,16 @@ private function extractRoute($routeObj): string { * @param mixed $routeObj The route object representing the endpoint. * @return string[] Returns a dictionary [ "class" => ..., "method" => ...] */ - private function extractMetadata($routeObj) { + 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) + // if the name is empty, the method will be called 'actionDefault' + if ($action === null) { $action = "default"; + } return [ "class" => $presenter . "Presenter", @@ -122,12 +135,12 @@ private function extractMetadata($routeObj) { */ private static function getPropertyValue($object, string $propertyName): mixed { - $class = new \ReflectionClass($object); + $class = new ReflectionClass($object); do { try { $property = $class->getProperty($propertyName); - } catch (\ReflectionException $exception) { + } catch (ReflectionException $exception) { $class = $class->getParentClass(); $property = null; } diff --git a/app/helpers/Swagger/AnnotationData.php b/app/helpers/Swagger/AnnotationData.php new file mode 100644 index 000000000..a20a7d007 --- /dev/null +++ b/app/helpers/Swagger/AnnotationData.php @@ -0,0 +1,79 @@ +httpMethod = $httpMethod; + $this->pathParams = $pathParams; + $this->queryParams = $queryParams; + $this->bodyParams = $bodyParams; + } + + private function getHttpMethodAnnotation(): string + { + // sample: converts 'PUT' to 'Put' + $httpMethodString = ucfirst(strtolower($this->httpMethod->name)); + return "@OA\\" . $httpMethodString; + } + + private function getBodyAnnotation(): string | null + { + if (count($this->bodyParams) === 0) { + return null; + } + + ///TODO: only supports JSON + $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: placeholder + $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 index 9a01b17f8..cd5721f44 100644 --- a/app/helpers/Swagger/AnnotationHelper.php +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -2,343 +2,59 @@ namespace App\Helpers\Swagger; -use App\Helpers\Notifications\ReviewsEmailsSender; -use App\Model\Repository\AssignmentSolutions; -use App\Model\Entity\Group; -use App\Model\Entity\User; -use App\V1Module\Router\MethodRoute; -use Nette\Routing\RouteList; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; -use Doctrine\Common\Annotations\AnnotationReader; -use DateTime; - - -enum HttpMethods: string { - case GET = "@GET"; - case POST = "@POST"; - case PUT = "@PUT"; - case DELETE = "@DELETE"; -} - -/** - * Builder class that handles .php file creation. - */ -class FileBuilder { - private $file; - private $methodEntries; - - public function __construct( - string $filename - ) { - $this->initFile($filename); - $this->methodEntries = 0; - } - - private function initFile(string $filename) { - $this->file = fopen($filename, "w"); - fwrite($this->file, "file, "/// THIS FILE WAS AUTOGENERATED\n"); - fwrite($this->file, "namespace App\V1Module\Presenters;\n"); - fwrite($this->file, "use OpenApi\Annotations as OA;\n"); - } - - 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) { - fwrite($this->file, "/**\n"); - fwrite($this->file, "* {$annotationLine}\n"); - fwrite($this->file, "*/\n"); - } - - public function startClass(string $className, string $version, string $title) { - $this->writeAnnotationLineWithComments($this->createInfoAnnotation($version, $title)); - fwrite($this->file, "class {$className} {\n"); - } - - public function endClass(){ - fwrite($this->file, "}\n"); - fflush($this->file); - fclose($this->file); - } - - public function addAnnotatedMethod(string $methodName, string $annotationLine) { - $this->writeAnnotationLineWithComments($annotationLine); - fwrite($this->file, "public function {$methodName}{$this->methodEntries}() {}\n"); - $this->methodEntries++; - } -} - -/** - * Builder class that can create strings of the schema: '(key1="value1", key2="value2", standalone1, standalone2, ...)' - */ -class ParenthesesBuilder { - private array $tokens; - - public function __construct() { - $this->tokens = []; - } - - 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 - else if (is_bool($value)) - $valueString = ($value ? "true" : "false"); - - $assignment = "{$key}={$valueString}"; - return $this->addValue($assignment); - } - - public function addValue(string $value): ParenthesesBuilder { - $this->tokens[] = $value; - return $this; - } - - public function toString(): string { - return '(' . implode(',', $this->tokens) . ')'; - } -} - -class AnnotationData { - public HttpMethods $httpMethod; - - public array $pathParams; - public array $queryParams; - public array $bodyParams; - - public function __construct( - HttpMethods $httpMethod, - array $pathParams, - array $queryParams, - array $bodyParams - ) { - $this->httpMethod = $httpMethod; - $this->pathParams = $pathParams; - $this->queryParams = $queryParams; - $this->bodyParams = $bodyParams; - } - - private function getHttpMethodAnnotation(): string { - # sample: converts '@PUT' to 'Put' - $httpMethodString = ucfirst(strtolower(substr($this->httpMethod->value, 1))); - return "@OA\\" . $httpMethodString; - } - - private function getBodyAnnotation(): string|null { - if (count($this->bodyParams) === 0) { - return null; - } - - ///TODO: only supports JSON - $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: placeholder - $body->addValue('@OA\Response(response="200",description="The data")'); - return $httpMethodAnnotation . $body->toString(); - } -} - - -/** - * Contains data of a single annotation parameter. - */ -class AnnotationParameterData { - public string|null $dataType; - public string $name; - public string|null $description; - public string $location; - - private static $nullableSuffix = '|null'; - private static $typeMap = [ - 'bool' => '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; - } - - 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 - return 'string'; - //throw new \InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); - - $type = self::$typeMap[$typename]; - } - return $type; - } - - 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(); - } - - public function toPropertyAnnotation(): string { - $head = "@OA\\Property"; - $body = new ParenthesesBuilder(); - - ///TODO: handle nullability - $body->addKeyValue("property", $this->name); - $body->addKeyValue("type", $this->getSwaggerType()); - return $head . $body->toString(); - } -} - +use ReflectionClass; +use Exception; /** - * Parser that can parse the annotations of existing recodex endpoints + * Parser that can parse the annotations of existing recodex endpoints. */ -class AnnotationHelper { - private static function getMethod(string $className, string $methodName): \ReflectionMethod { - $class = new \ReflectionClass($className); +class AnnotationHelper +{ + private static function getMethod(string $className, string $methodName): \ReflectionMethod + { + $class = new ReflectionClass($className); return $class->getMethod($methodName); } - private static function extractAnnotationHttpMethod(array $annotations): HttpMethods|null { - # get string values of backed enumeration + private static function extractAnnotationHttpMethod(array $annotations): HttpMethods | null + { + // get string values of backed enumeration $cases = HttpMethods::cases(); $methods = []; foreach ($cases as $case) { - $methods[] = $case->value; + $methods["@{$case->name}"] = $case; } - # check if the annotations have a http method - foreach ($methods as $method) { - if (in_array($method, $annotations)) { - return HttpMethods::from($method); + // check if the annotations have an http method + foreach ($methods as $methodString => $methodEnum) { + if (in_array($methodString, $annotations)) { + return $methodEnum; } } return null; } - private static function extractStandardAnnotationParams(array $annotations, string $route): array { + 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 + // assumed that all query parameters have a @param annotation if (str_starts_with($annotation, "@param")) { - # sample: @param string $id Identifier of the user + // sample: @param string $id Identifier of the user $tokens = explode(" ", $annotation); $type = $tokens[1]; - # assumed that all names start with $ + // assumed that all names start with $ $name = substr($tokens[2], 1); - $description = implode(" ", array_slice($tokens,3)); + $description = implode(" ", array_slice($tokens, 3)); - # figure out where the parameter is located + // figure out where the parameter is located $location = 'query'; - if (in_array($name, $routeParams)) + if (in_array($name, $routeParams)) { $location = 'path'; + } $descriptor = new AnnotationParameterData($type, $name, $description, $location); $params[] = $descriptor; @@ -347,63 +63,72 @@ private static function extractStandardAnnotationParams(array $annotations, stri return $params; } - private static function extractBodyParams(array $expressions): array { + private static function extractBodyParams(array $expressions): array + { $dict = []; - #sample: [ 'name="uiData"', 'validation="array|null"' ] + //sample: [ 'name="uiData"', 'validation="array|null"' ] foreach ($expressions as $expression) { $tokens = explode('="', $expression); $name = $tokens[0]; - # remove the '"' at the end + // remove the '"' at the end $value = substr($tokens[1], 0, -1); $dict[$name] = $value; } return $dict; } - private static function extractNetteAnnotationParams(array $annotations): array { + private static function extractNetteAnnotationParams(array $annotations): array + { $bodyParams = []; $prefix = "@Param"; foreach ($annotations as $annotation) { - # assumed that all body parameters have a @Param 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 + // 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::extractBodyParams($tokens); - $descriptor = new AnnotationParameterData($values["validation"], - $values["name"], $values["description"], $values["type"]); + $descriptor = new AnnotationParameterData( + $values["validation"], + $values["name"], + $values["description"], + $values["type"] + ); $bodyParams[] = $descriptor; } } return $bodyParams; } - private static function getMethodAnnotations(string $className, string $methodName): array { + 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 + // 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) + // 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] !== "@") + // skip lines not starting with '@' + if ($line[0] !== "@") { continue; + } - # merge lines not starting with '@' with their parent lines starting with '@' + // 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++; @@ -415,13 +140,15 @@ private static function getMethodAnnotations(string $className, string $methodNa return $merged; } - private static function getRoutePathParamNames(string $route): array { - # sample: from '/users/{id}/{name}' generates ['id', 'name'] + 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]; } - public static function extractAnnotationData(string $className, string $methodName, string $route): AnnotationData { + public static function extractAnnotationData(string $className, string $methodName, string $route): AnnotationData + { $methodAnnotations = self::getMethodAnnotations($className, $methodName); $httpMethod = self::extractAnnotationHttpMethod($methodAnnotations); @@ -434,14 +161,15 @@ public static function extractAnnotationData(string $className, string $methodNa $bodyParams = []; foreach ($params as $param) { - if ($param->location === 'path') + if ($param->location === 'path') { $pathParams[] = $param; - else if ($param->location === 'query') + } elseif ($param->location === 'query') { $queryParams[] = $param; - else if ($param->location === 'post') + } elseif ($param->location === 'post') { $bodyParams[] = $param; - else - throw new \Exception("Error in extractAnnotationData: Unknown param location: {$param->location}"); + } else { + throw new Exception("Error in extractAnnotationData: Unknown param location: {$param->location}"); + } } @@ -449,7 +177,8 @@ public static function extractAnnotationData(string $className, string $methodNa return $data; } - private static function filterAnnotations(array $annotations, string $type) { + private static function filterAnnotations(array $annotations, string $type) + { $rows = []; foreach ($annotations as $annotation) { if (str_starts_with($annotation, $type)) { @@ -457,13 +186,14 @@ private static function filterAnnotations(array $annotations, string $type) { } } return $rows; - } + } - private static function extractFormatData(array $annotations): array { + private static function extractFormatData(array $annotations): array + { $formats = []; $filtered = self::filterAnnotations($annotations, "@format_def"); foreach ($filtered as $annotation) { - # sample: @format user_info { "name":"format:name", "points":"format:int", "comments":"format:string[]" } + // sample: @format user_info { "name":"format:name", "points":"format:int", "comments":"format:string[]" } $tokens = explode(" ", $annotation); $name = $tokens[1]; @@ -476,12 +206,14 @@ private static function extractFormatData(array $annotations): array { return $formats; } - private static function extractMethodFormats(string $className, string $methodName): array { + private static function extractMethodFormats(string $className, string $methodName): array + { $annotations = self::getMethodAnnotations($className, $methodName); return self::extractFormatData($annotations); } - public static function extractClassFormats(string $className): array { + public static function extractClassFormats(string $className): array + { $methods = get_class_methods($className); $formatDicts = []; foreach ($methods as $method) { @@ -491,7 +223,8 @@ public static function extractClassFormats(string $className): array { return array_merge(...$formatDicts); } - public static function extractMethodCheckedParams(string $className, string $methodName): array { + public static function extractMethodCheckedParams(string $className, string $methodName): array + { $annotations = self::getMethodAnnotations($className, $methodName); $filtered = self::filterAnnotations($annotations, "@checked_param"); @@ -507,10 +240,11 @@ public static function extractMethodCheckedParams(string $className, string $met return $paramMap; } - public static function extractClassFormat(string $className) { - $class = new \ReflectionClass($className); + public static function extractClassFormat(string $className) + { + $class = new ReflectionClass($className); $fields = get_class_vars($className); - foreach ($fields as $fieldName=>$value) { + foreach ($fields as $fieldName => $value) { $field = $class->getProperty($fieldName); $fieldType = $field->getType()->getName(); var_dump($fieldType); diff --git a/app/helpers/Swagger/AnnotationParameterData.php b/app/helpers/Swagger/AnnotationParameterData.php new file mode 100644 index 000000000..9e2b0adf5 --- /dev/null +++ b/app/helpers/Swagger/AnnotationParameterData.php @@ -0,0 +1,128 @@ + '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; + } + + 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 + return 'string'; + } + //throw new \InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); + + $type = self::$typeMap[$typename]; + } + return $type; + } + + 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(); + } + + public function toPropertyAnnotation(): string + { + $head = "@OA\\Property"; + $body = new ParenthesesBuilder(); + + ///TODO: handle nullability + $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..cd6837b1e --- /dev/null +++ b/app/helpers/Swagger/HttpMethods.php @@ -0,0 +1,13 @@ +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..52a08a3da --- /dev/null +++ b/app/helpers/Swagger/TempAnnotationFileBuilder.php @@ -0,0 +1,76 @@ +content = ""; + $this->methodEntries = 0; + $this->filename = $filename; + $this->initFile(); + } + + 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"; + } + + 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"; + } + + 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(); + } + + public function addAnnotatedMethod(string $methodName, string $annotationLine) + { + $this->writeAnnotationLineWithComments($annotationLine); + $this->content .= "public function {$methodName}{$this->methodEntries}() {}\n"; + $this->methodEntries++; + } + + public function close() + { + $file = fopen($this->filename, "w"); + fwrite($file, $this->content); + fflush($file); + fclose($file); + } +} From 85b1e208fd154c3a1531ec7a3fd7bebe759d3481 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 5 Dec 2024 19:43:02 +0100 Subject: [PATCH 19/25] fixed unresolved merge conflict --- composer.lock | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/composer.lock b/composer.lock index 49f228280..70da9f231 100644 --- a/composer.lock +++ b/composer.lock @@ -7049,18 +7049,6 @@ }, { "name": "symfony/process", -<<<<<<< HEAD - "version": "v7.1.5", - "source": { - "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "5c03ee6369281177f07f7c68252a280beccba847" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/5c03ee6369281177f07f7c68252a280beccba847", - "reference": "5c03ee6369281177f07f7c68252a280beccba847", -======= "version": "v7.1.7", "source": { "type": "git", @@ -7071,7 +7059,6 @@ "type": "zip", "url": "https://api.github.com/repos/symfony/process/zipball/9b8a40b7289767aa7117e957573c2a535efe6585", "reference": "9b8a40b7289767aa7117e957573c2a535efe6585", ->>>>>>> master "shasum": "" }, "require": { From 0b42c856775a38d527b82b0eb75a9cbc12c581ae Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Thu, 5 Dec 2024 22:00:41 +0100 Subject: [PATCH 20/25] removed unused method --- app/helpers/Swagger/AnnotationHelper.php | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/app/helpers/Swagger/AnnotationHelper.php b/app/helpers/Swagger/AnnotationHelper.php index cd5721f44..85f4c982b 100644 --- a/app/helpers/Swagger/AnnotationHelper.php +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -239,15 +239,4 @@ public static function extractMethodCheckedParams(string $className, string $met return $paramMap; } - - public static function extractClassFormat(string $className) - { - $class = new ReflectionClass($className); - $fields = get_class_vars($className); - foreach ($fields as $fieldName => $value) { - $field = $class->getProperty($fieldName); - $fieldType = $field->getType()->getName(); - var_dump($fieldType); - } - } } From 7923f190f1eee5c865627ea29d35580b638205b2 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Fri, 6 Dec 2024 17:48:59 +0100 Subject: [PATCH 21/25] replaced phpcs ignoreFile with a fine grained alternative --- app/helpers/Swagger/HttpMethods.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/helpers/Swagger/HttpMethods.php b/app/helpers/Swagger/HttpMethods.php index cd6837b1e..8bb508daf 100644 --- a/app/helpers/Swagger/HttpMethods.php +++ b/app/helpers/Swagger/HttpMethods.php @@ -1,9 +1,9 @@ Date: Fri, 6 Dec 2024 18:04:21 +0100 Subject: [PATCH 22/25] improved swagger command descriptions --- app/commands/GenerateSwagger.php | 3 ++- app/commands/SwaggerAnnotator.php | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index f52288920..a53a15176 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -14,7 +14,8 @@ class GenerateSwagger extends Command protected function configure() { $this->setName(self::$defaultName)->setDescription( - 'Generate a swagger specification file from existing code.' + 'Generate an OpenAPI documentation from the temporary file created by the swagger:annotate command.' + . ' The temporary file is deleted afterwards.' ); } diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index d04dfcdec..523609f55 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -21,8 +21,10 @@ class SwaggerAnnotator extends Command protected function configure(): void { + $filePath = self::$autogeneratedAnnotationFilePath; $this->setName(self::$defaultName)->setDescription( - 'Annotate all methods with Swagger PHP annotations.' + "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}" ); } From 4e9f815811e99da488fec8d46b1cb3a16a897b82 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Tue, 10 Dec 2024 12:03:14 +0100 Subject: [PATCH 23/25] made TODOs more future-proof --- app/helpers/Swagger/AnnotationData.php | 5 +++-- app/helpers/Swagger/AnnotationParameterData.php | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/helpers/Swagger/AnnotationData.php b/app/helpers/Swagger/AnnotationData.php index a20a7d007..2bb538416 100644 --- a/app/helpers/Swagger/AnnotationData.php +++ b/app/helpers/Swagger/AnnotationData.php @@ -38,7 +38,7 @@ private function getBodyAnnotation(): string | null return null; } - ///TODO: only supports JSON + ///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(); @@ -72,7 +72,8 @@ public function toSwaggerAnnotations(string $route) $body->addValue($jsonProperties); } - ///TODO: placeholder + ///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/AnnotationParameterData.php b/app/helpers/Swagger/AnnotationParameterData.php index 9e2b0adf5..0f4e31b92 100644 --- a/app/helpers/Swagger/AnnotationParameterData.php +++ b/app/helpers/Swagger/AnnotationParameterData.php @@ -76,10 +76,11 @@ private function getSwaggerType(): string } if (self::$typeMap[$typename] === null) { - ///TODO: return the commented exception + ///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}"); + //throw new \InvalidArgumentException("Error in getSwaggerType: Unknown typename: {$typename}"); $type = self::$typeMap[$typename]; } @@ -120,7 +121,7 @@ public function toPropertyAnnotation(): string $head = "@OA\\Property"; $body = new ParenthesesBuilder(); - ///TODO: handle nullability + ///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(); From 4c0560d899b1eb71a8d4f4b7234eb0d5009006b5 Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Wed, 11 Dec 2024 11:46:17 +0100 Subject: [PATCH 24/25] added more comments --- app/commands/GenerateSwagger.php | 5 +- app/commands/SwaggerAnnotator.php | 7 +- app/helpers/Swagger/AnnotationData.php | 13 +++- app/helpers/Swagger/AnnotationHelper.php | 67 +++++++++++++++++-- .../Swagger/AnnotationParameterData.php | 12 ++++ .../Swagger/TempAnnotationFileBuilder.php | 26 ++++++- 6 files changed, 122 insertions(+), 8 deletions(-) diff --git a/app/commands/GenerateSwagger.php b/app/commands/GenerateSwagger.php index a53a15176..348d3f82e 100644 --- a/app/commands/GenerateSwagger.php +++ b/app/commands/GenerateSwagger.php @@ -5,8 +5,11 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use \OpenApi\Generator; +use OpenApi\Generator; +/** + * Command that consumes a temporary file containing endpoint annotations and generates a swagger documentation. + */ class GenerateSwagger extends Command { protected static $defaultName = 'swagger:generate'; diff --git a/app/commands/SwaggerAnnotator.php b/app/commands/SwaggerAnnotator.php index 523609f55..1c9c9321b 100644 --- a/app/commands/SwaggerAnnotator.php +++ b/app/commands/SwaggerAnnotator.php @@ -13,6 +13,11 @@ use ReflectionException; use ReflectionClass; +/** + * Command that creates a temporary file for swagger documentation generation. + * The command uses the RouterFactory to find all endpoints. + * The temporary file is consumed by the swagger:generate command. + */ class SwaggerAnnotator extends Command { protected static $defaultName = 'swagger:annotate'; @@ -66,7 +71,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int private function getRoutes(): array { $router = \App\V1Module\RouterFactory::createRouter(); - + // find all route object using a queue $queue = [$router]; $routes = []; diff --git a/app/helpers/Swagger/AnnotationData.php b/app/helpers/Swagger/AnnotationData.php index 2bb538416..977a5a1fa 100644 --- a/app/helpers/Swagger/AnnotationData.php +++ b/app/helpers/Swagger/AnnotationData.php @@ -8,7 +8,7 @@ class AnnotationData { public HttpMethods $httpMethod; - + public array $pathParams; public array $queryParams; public array $bodyParams; @@ -25,6 +25,11 @@ public function __construct( $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' @@ -32,6 +37,12 @@ private function getHttpMethodAnnotation(): string 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) { diff --git a/app/helpers/Swagger/AnnotationHelper.php b/app/helpers/Swagger/AnnotationHelper.php index 85f4c982b..b0200048d 100644 --- a/app/helpers/Swagger/AnnotationHelper.php +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -3,6 +3,7 @@ namespace App\Helpers\Swagger; use ReflectionClass; +use ReflectionMethod; use Exception; /** @@ -10,15 +11,26 @@ */ class AnnotationHelper { - private static function getMethod(string $className, string $methodName): \ReflectionMethod + /** + * Returns a ReflectionMethod object matching the name of the method and containing class. + * @param string $className The name of the containing class. + * @param string $methodName The name of the method. + * @return \ReflectionMethod Returns the ReflectionMethod object. + */ + private static function getMethod(string $className, string $methodName): ReflectionMethod { $class = new ReflectionClass($className); return $class->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 values of backed enumeration + // get string names of the enumeration $cases = HttpMethods::cases(); $methods = []; foreach ($cases as $case) { @@ -35,6 +47,14 @@ private static function extractAnnotationHttpMethod(array $annotations): HttpMet 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); @@ -63,7 +83,12 @@ private static function extractStandardAnnotationParams(array $annotations, stri return $params; } - private static function extractBodyParams(array $expressions): array + /** + * 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"' ] @@ -77,6 +102,11 @@ private static function extractBodyParams(array $expressions): array 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 = []; @@ -89,7 +119,7 @@ private static function extractNetteAnnotationParams(array $annotations): array // remove '@Param(' from the start and ')' from the end $body = substr($annotation, strlen($prefix) + 1, -1); $tokens = explode(", ", $body); - $values = self::extractBodyParams($tokens); + $values = self::stringArrayToAssociativeArray($tokens); $descriptor = new AnnotationParameterData( $values["validation"], $values["name"], @@ -102,6 +132,14 @@ private static function extractNetteAnnotationParams(array $annotations): array 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(); @@ -140,6 +178,11 @@ private static function getMethodAnnotations(string $className, string $methodNa 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'] @@ -147,6 +190,16 @@ private static function getRoutePathParamNames(string $route): array 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); @@ -177,6 +230,12 @@ public static function extractAnnotationData(string $className, string $methodNa 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. + */ private static function filterAnnotations(array $annotations, string $type) { $rows = []; diff --git a/app/helpers/Swagger/AnnotationParameterData.php b/app/helpers/Swagger/AnnotationParameterData.php index 0f4e31b92..d915f32d3 100644 --- a/app/helpers/Swagger/AnnotationParameterData.php +++ b/app/helpers/Swagger/AnnotationParameterData.php @@ -65,6 +65,10 @@ private function isDatatypeNullable(): bool 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 @@ -87,6 +91,10 @@ private function getSwaggerType(): string return $type; } + /** + * Generates swagger schema annotations based on the data type. + * @return string Returns the annotation. + */ private function generateSchemaAnnotation(): string { $head = "@OA\\Schema"; @@ -116,6 +124,10 @@ public function toParameterAnnotation(): string 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"; diff --git a/app/helpers/Swagger/TempAnnotationFileBuilder.php b/app/helpers/Swagger/TempAnnotationFileBuilder.php index 52a08a3da..bd72ccb3e 100644 --- a/app/helpers/Swagger/TempAnnotationFileBuilder.php +++ b/app/helpers/Swagger/TempAnnotationFileBuilder.php @@ -20,6 +20,9 @@ public function __construct( $this->initFile(); } + /** + * Initializes the file, adding the namespace and import statements. + */ private function initFile() { $this->content .= "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"; @@ -44,6 +53,13 @@ private function writeAnnotationLineWithComments(string $annotationLine) $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)); @@ -59,6 +75,11 @@ public function endClass() $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); @@ -66,7 +87,10 @@ public function addAnnotatedMethod(string $methodName, string $annotationLine) $this->methodEntries++; } - public function close() + /** + * Creates a file and adds the swagger content. + */ + private function close() { $file = fopen($this->filename, "w"); fwrite($file, $this->content); From 5fe5c49879cb879145daf175c98c2457943d40cb Mon Sep 17 00:00:00 2001 From: Vojtech Kloda Date: Wed, 11 Dec 2024 11:47:41 +0100 Subject: [PATCH 25/25] removed unused methods --- app/helpers/Swagger/AnnotationHelper.php | 54 +----------------------- 1 file changed, 1 insertion(+), 53 deletions(-) diff --git a/app/helpers/Swagger/AnnotationHelper.php b/app/helpers/Swagger/AnnotationHelper.php index b0200048d..1b2ba8840 100644 --- a/app/helpers/Swagger/AnnotationHelper.php +++ b/app/helpers/Swagger/AnnotationHelper.php @@ -236,7 +236,7 @@ public static function extractAnnotationData(string $className, string $methodNa * @param string $type The prefix with which the lines should start, such as '@param'. * @return array Returns an array of filtered annotations. */ - private static function filterAnnotations(array $annotations, string $type) + public static function filterAnnotations(array $annotations, string $type) { $rows = []; foreach ($annotations as $annotation) { @@ -246,56 +246,4 @@ private static function filterAnnotations(array $annotations, string $type) } return $rows; } - - private static function extractFormatData(array $annotations): array - { - $formats = []; - $filtered = self::filterAnnotations($annotations, "@format_def"); - foreach ($filtered as $annotation) { - // sample: @format user_info { "name":"format:name", "points":"format:int", "comments":"format:string[]" } - $tokens = explode(" ", $annotation); - $name = $tokens[1]; - - $jsonStart = strpos($annotation, "{"); - $json = substr($annotation, $jsonStart); - $format = json_decode($json); - - $formats[$name] = $format; - } - return $formats; - } - - private static function extractMethodFormats(string $className, string $methodName): array - { - $annotations = self::getMethodAnnotations($className, $methodName); - return self::extractFormatData($annotations); - } - - public static function extractClassFormats(string $className): array - { - $methods = get_class_methods($className); - $formatDicts = []; - foreach ($methods as $method) { - $formatDicts[] = self::extractMethodFormats($className, $method); - } - - return array_merge(...$formatDicts); - } - - public static function extractMethodCheckedParams(string $className, string $methodName): array - { - $annotations = self::getMethodAnnotations($className, $methodName); - $filtered = self::filterAnnotations($annotations, "@checked_param"); - - $paramMap = []; - foreach ($filtered as $annotation) { - // sample: @checked_param format:group group - $tokens = explode(" ", $annotation); - $format = $tokens[1]; - $name = $tokens[2]; - $paramMap[$name] = $format; - } - - return $paramMap; - } }