diff --git a/.gitignore b/.gitignore index 0fd1213..3d82056 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,7 @@ composer.lock #~ Overridden condigs phpstan.neon phpunit.xml + +#~ Example generated part +example/* +!example/.gitkeep diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index d16dbbd..e934c75 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -5,7 +5,7 @@ ->setRules( [ '@PER-CS3x0' => true, - ] + ], ) //~ Format @@ -20,5 +20,7 @@ [ __DIR__ . '/src', __DIR__ . '/tests', - ])) + __DIR__ . '/example', + ], + )) ; diff --git a/CHANGELOG.md b/CHANGELOG.md index dea9e36..ddc55e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ```yaml ## [tag] - YYYY-MM-DD -[tag]: https://github.com/eureka-framework/component-template/compare/1.0.0...master +[tag]: https://github.com/eureka-framework/component-client-api-generator/compare/1.0.0...master ### Changed - Change 1 ### Added @@ -17,40 +17,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ---- -## [2.0.0] - 2026-01-05 -### Added -- PHP 8.5 support -### Removed -- PHP support of versions < 8.3 -### Changed -- Update Makefile -- Update CI configs -- Update dependencies - ----- - -## [1.1.0] - 2023-03-15 -### Changed -- Update Makefile -- Update CI GitHub Action -- Update composer.json -- Now compatible with PHP 8.2 - -## [1.0.2] - 2022-11-13 -### Changed -- Update Makefile -- Update CI GitHub Action -- Update composer.json - -## [1.0.1] - 2022-06-12 -### Changed -- Fix changelog template link -- Update github workflow ci file - ## [1.0.0] - 2022-06-12 ### Added -- Add phpstan for static analyze & php compatibility -- Add Check compatibility with PHP 7.4 & 8.1 in CI -- Add Makefile & .dist files for CI -- Add Dummy Source & Dummy Test -- Add GitHub CI file & sonar project file + - Add initial version of the component client API generator. diff --git a/Makefile b/Makefile index 199be3e..7693863 100644 --- a/Makefile +++ b/Makefile @@ -78,3 +78,16 @@ clean: @if [ "$(shell ls -A ./build)" ]; then rm -rf ./build/*; fi; echo " done" ci: clean validate install php/deps php/check php/tests php/integration php/min-compatibility php/max-compatibility php/analyze + +################################################################################################################# +# Client API Generator + +generate/query-mercury: + $(call header,Generating Client API) + @./vendor/bin/console ClientApiGenerator/Script/Generator --debug --config=example/query-mercury/config.json + @XDEBUG_MODE=off ./vendor/bin/php-cs-fixer fix -q + +generate/artifacts-mmo: + $(call header,Generating Client API) + @./vendor/bin/console ClientApiGenerator/Script/Generator --debug --config=example/artifacts-mmo/config.json + @XDEBUG_MODE=off ./vendor/bin/php-cs-fixer fix -q diff --git a/README.md b/README.md index 9942551..1504d87 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# component-template -[![Current version](https://img.shields.io/packagist/v/eureka/component-template.svg?logo=composer)](https://packagist.org/packages/eureka/component-template) -[![Supported PHP version](https://img.shields.io/static/v1?logo=php&label=PHP&message=>%3D8.3&color=777bb4)](https://packagist.org/packages/eureka/component-template) -![CI](https://github.com/eureka-framework/component-template/workflows/CI/badge.svg) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=eureka-framework_component-template&metric=alert_status)](https://sonarcloud.io/dashboard?id=eureka-framework_component-template) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=eureka-framework_component-template&metric=coverage)](https://sonarcloud.io/dashboard?id=eureka-framework_component-template) +# component-client-api-generator +[![Current version](https://img.shields.io/packagist/v/eureka/component-client-api-generator.svg?logo=composer)](https://packagist.org/packages/eureka/component-client-api-generator) +[![Supported PHP version](https://img.shields.io/static/v1?logo=php&label=PHP&message=>%3D8.3&color=777bb4)](https://packagist.org/packages/eureka/component-client-api-generator) +![CI](https://github.com/eureka-framework/component-client-api-generator/workflows/CI/badge.svg) +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=eureka-framework_component-client-api-generator&metric=alert_status)](https://sonarcloud.io/dashboard?id=eureka-framework_component-client-api-generator) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=eureka-framework_component-client-api-generator&metric=coverage)](https://sonarcloud.io/dashboard?id=eureka-framework_component-client-api-generator) ## Why? -Template for new components +Libs to generate Client API / SDK based on OpenApi json schema. @@ -16,7 +16,7 @@ Template for new components If you wish to install it in your project, require it via composer: ```bash -composer require eureka/component-template +composer require eureka/component-client-api-generator ``` diff --git a/composer.json b/composer.json index c6565b6..74f8d9e 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { - "name": "eureka/component-template", - "description": "Template for new component & domain", + "name": "eureka/component-client-api-generator", + "description": "Libs to generate Client API / SDK based on openapi json schema.", "type": "library", "minimum-stability": "stable", "license": "MIT", @@ -14,12 +14,13 @@ "autoload": { "psr-4": { - "Eureka\\Component\\Template\\": "./src" + "Eureka\\Component\\ClientApiGenerator\\": "./src", + "Eureka\\Component\\ClientApiGenerator\\Script\\": "./scripts" } }, "autoload-dev": { "psr-4": { - "Eureka\\Component\\Template\\Tests\\": "./tests" + "Eureka\\Component\\ClientApiGenerator\\Tests\\": "./tests" } }, @@ -31,17 +32,20 @@ }, "require": { - "php": ">=8.3" + "php": ">=8.3", + "cebe/php-openapi": "^1.0", + "eureka/component-console": "^7.0", + "nikic/php-parser": "^5.0" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.95.2", - "phpstan/phpstan": "^2.1.55", + "phpstan/phpstan": "^2.1.56", "phpstan/phpstan-deprecation-rules": "^2.0.4", "phpstan/phpstan-phpunit": "^2.0.16", "phpstan/phpstan-strict-rules": "^2.0.11", "phpunit/phpcov": "^11.0.4", - "phpunit/phpunit": "^12.5.25", + "phpunit/phpunit": "^12.5.28", "shipmonk/composer-dependency-analyser": "^1.8.4" } } diff --git a/example/.gitkeep b/example/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 321e2a0..9e066b6 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,6 +11,13 @@ parameters: paths: - ./src - ./tests + - ./scripts bootstrapFiles: - ./vendor/autoload.php + + treatPhpDocTypesAsCertain: false + strictRules: + disallowedEmpty: false + + ignoreErrors: [] diff --git a/scripts/Generator.php b/scripts/Generator.php new file mode 100644 index 0000000..e1002ac --- /dev/null +++ b/scripts/Generator.php @@ -0,0 +1,140 @@ +setDescription('Orm generator'); + $this->setExecutable(); + + $this->initOptions( + (new Options()) + ->add(new Option('c', 'config', 'Json config file for generator', mandatory: false, hasArgument: true)) + ->add(new Option('f', 'file', 'OpenAPI file to process', mandatory: false, hasArgument: true)) + ->add(new Option('n', 'namespace', 'Base namespace for all generated classes', mandatory: false, hasArgument: true)) + ->add(new Option('d', 'destination', 'Destination path for generated code', mandatory: false, hasArgument: true)), + ); + } + + public function help(): void + { + (new Help('...', $this->declaredOptions(), $this->output(), $this->options()))->display(); + } + + /** + * @throws IOException + * @throws TypeErrorException + * @throws UnresolvableReferenceException + * @throws InvalidJsonPointerSyntaxException + * @throws \JsonException + */ + public function run(): void + { + $config = $this->initConfig(); + + $builderFactory = new BuilderFactory(); + $schemaResolver = new SchemaResolver($config); + $typeResolver = new TypeResolver($config); + $jsonApiSchema = new JsonApiSchema(); + $voBuilder = new VOBuilder($builderFactory, $schemaResolver, $typeResolver); + $formatterBuilder = new FormatterBuilder($builderFactory, $voBuilder, $schemaResolver, $typeResolver); + $clientBuilder = new ClientBuilder($builderFactory, $formatterBuilder, $voBuilder, $jsonApiSchema, $schemaResolver, $typeResolver); + + if (\str_ends_with($config->file, '.yaml')) { + $openapi = Reader::readFromYamlFile((string) \realpath($config->file)); + } else { + $openapi = Reader::readFromJsonFile((string) \realpath($config->file)); + } + + //~ Add each path as client + $paths = $openapi->paths->getPaths(); + foreach ($paths as $path => $pathItem) { + if ($config->isIgnoredPath($path)) { + continue; + } + $clientBuilder->add($path, $pathItem); + } + + if (!\is_dir($config->destination) && !\mkdir($config->destination, 0755, true)) { + throw new \UnexpectedValueException("Failed to create directory '$config->destination' !"); + } + + $clientBuilder->generate($config->destination, $config->namespace); + $formatterBuilder->generate($config->destination, $config->namespace); + $voBuilder->generate($config->destination, $config->namespace); + } + + /** + * @throws \JsonException + */ + private function initConfig(): Config + { + $configData = []; + + $configFile = \trim((string) $this->options()->value('c', 'config')); + if (\file_exists($configFile)) { + /** @var ConfigData $configData */ + $configData = \json_decode((string) \file_get_contents($configFile), associative: true, flags: \JSON_THROW_ON_ERROR); + } + + $file = $this->options()->value('f', 'file'); + $destination = $this->options()->value('d', 'destination'); + $namespace = $this->options()->value('n', 'namespace'); + + $file = \trim((string) ($file ?? $configData['file'] ?? '')); + if (!\file_exists($file)) { + throw new \UnexpectedValueException('Cannot found file'); + } + + $schemas = []; + if (isset($configData['custom']['schemas']) && $configData['custom']['schemas'] !== []) { + foreach ($configData['custom']['schemas'] as $name => $definition) { + $schemas[$name] = new Schema($definition); + } + } + + return new Config( + $file, + (string) ($namespace ?? $configData['namespace'] ?? ''), + (string) ($destination ?? $configData['destination'] ?? ''), + $configData['ignorePaths'] ?? [], + $configData['overrides']['types'] ?? [], + $configData['overrides']['schemas'] ?? [], + $schemas, + ); + } +} diff --git a/sonar-project.properties b/sonar-project.properties index 4590b68..b88158a 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,6 @@ # Project sonar.organization=eureka-framework -sonar.projectKey=eureka-framework_component-template +sonar.projectKey=eureka-framework_component-client-api-generator # Path to sources sonar.sources=src/ diff --git a/src/Builder/ClientMethod.php b/src/Builder/ClientMethod.php new file mode 100644 index 0000000..1b17e58 --- /dev/null +++ b/src/Builder/ClientMethod.php @@ -0,0 +1,90 @@ +var('endpoint'); + + if (empty($pathParams)) { + $this->addStmt(new Assign($endpointVar, new String_($path))); + + return $this; + } + + //~ Use variable in path + $replace = []; + foreach ($pathParams as $pathParam) { + $replace['{' . $pathParam->name . '}'] = '$' . $pathParam->name; + } + $path = \str_replace(\array_keys($replace), \array_values($replace), $path); + $this->addStmt(new Assign($endpointVar, new String_($path, ['kind' => String_::KIND_DOUBLE_QUOTED, 'noEscape' => true]))); + + return $this; + } + + public function addCallBuilder(bool $withQuery, bool $withBody, OperationType $operationType): self + { + $factory = new BuilderFactory(); + + $params = [$factory->var('endpoint')]; + if ($withQuery) { + $params['query'] = $factory->var('query'); + } + if ($withBody) { + $params['body'] = $factory->methodCall($factory->var('body'), 'jsonSerialize'); + } + $params['method'] = strtoupper($operationType->name); + + $thisVar = $factory->var('this'); + $builderCall = $factory->methodCall($thisVar, 'getRequestBuilder'); + $chainBuildCall = $factory->methodCall($builderCall, 'build', $params); + $requestVar = $factory->var('request'); + + $this->addStmt(new Assign($requestVar, $chainBuildCall)); + + return $this; + } + + public function addReturnData(Type $type): self + { + $factory = new BuilderFactory(); + + $requestVar = $factory->var('request'); + $formatterNew = $factory->new('Formatter\\' . $type->formatter()); + $params = [$requestVar, $formatterNew]; + if ($type->isArray()) { + $fetchCall = $factory->methodCall($factory->var('this'), 'fetchVOList', $params); + } else { + $fetchCall = $factory->methodCall($factory->var('this'), 'fetchVO', $params); + } + + $this->addStmt(new Return_($fetchCall)); + + return $this; + } +} diff --git a/src/Builder/FormatterClass.php b/src/Builder/FormatterClass.php new file mode 100644 index 0000000..89bf841 --- /dev/null +++ b/src/Builder/FormatterClass.php @@ -0,0 +1,154 @@ +real('VO')}>"; + } + + if (isset($interfaces['ListFormatterInterface'])) { + $phpdoc[] = " * @implements ListFormatterInterface<{$type->real('VO')}>"; + } + + if ($type->isValueObject()) { + $phpdoc[] = " * @phpstan-import-type {$type->real(suffix: 'InputData')} from {$type->real('VO')}"; + } + + $phpdoc[] = ' */'; + + $this->setDocComment(new Doc(\implode("\n", $phpdoc))); + + return $this; + } + + public function addFormatItemMethod(Type $type): self + { + $factory = new BuilderFactory(); + + $method = $factory->method('formatItem') + ->makeStatic() + ->makePublic() + ->addParams([$factory->param('data')]) + ->setReturnType($type->real('VO')) + ->setDocComment(new Doc("\n/**\n * @param {$type->real(suffix: 'InputData')} \$data\n */")) + ; + + if ($type->isValueObject()) { + $phpdoc = []; + $args = $this->buildConstructorArgs($this->schema, $phpdoc); + $method->addStmt(new Return_(new New_(new Name($type->real('VO')), $args))); + } else { + $var = new Variable('data'); + $cast = match ($type->real()) { + 'int' => new Int_($var), + 'bool' => new Bool_($var), + 'float' => new Double($var), + 'array' => new Array_($var), + default => new String_($var), + }; + $method->addStmt(new Return_($cast)); + } + + $this->addStmt($method); + + return $this; + } + + /** + * @param list $phpdoc + * @return Arg[] + */ + private function buildConstructorArgs(Schema $schema, array &$phpdoc): array + { + $factory = new BuilderFactory(); + $params = []; + + /** @var list $required */ + $required = $schema->type === 'array' ? $schema->items->required ?? [] : $schema->required ?? []; + /** @var array $properties */ + $properties = $schema->type === 'array' ? $schema->items->properties ?? [] : $schema->properties; + + foreach ($properties as $name => $property) { + $property = $this->schemaResolver->resolve($property); + + $isVO = false; + $type = $this->typeResolver->resolve($property, \in_array($name, $required, true)); + $phpdoc[] = "$name: {$type->php()}"; + + if ($type->isArray() && ($type->subType?->isValueObject() ?? false)) { + $dataVar = new Variable("data->$name" . ($type->nullable ? ' ?? []' : '')); + $expression = $factory->staticCall($type->formatter(), 'formatItemList', [$dataVar]); + $isVO = true; + } elseif ($type->isValueObject()) { + $dataVar = new Variable("data->$name"); + $callMethod = $factory->staticCall($type->formatter(), 'formatItem', [$dataVar]); + $expression = $type->nullable ? new Ternary(new Isset_([$dataVar]), $callMethod, $factory->val(null)) : $callMethod; + $isVO = true; + } else { + $expression = new Variable("data->$name" . ($type->nullable ? ' ?? null' : '')); + } + + $params[] = new Arg($expression); + + if ($isVO) { + //~ Be sure we also have formatter & VO classes for this VO property + $this->formatterBuilder->add($property, $type); + $this->voBuilder->add($property, $type, 'VO'); // Be sure we also have for subtype + } + } + + return $params; + } +} diff --git a/src/Builder/VOClass.php b/src/Builder/VOClass.php new file mode 100644 index 0000000..9cbe88c --- /dev/null +++ b/src/Builder/VOClass.php @@ -0,0 +1,153 @@ +buildConstructorParams($schema); + + $method = $factory + ->method('__construct') + ->makePublic() + ->addParams($params) + ; + + if ($phpdoc !== []) { + \array_unshift($phpdoc, "\n", '/**'); + $phpdoc[] = ' */'; + $method->setDocComment(new Doc(\implode("\n", $phpdoc))); + } else { + $method->setDocComment(new Doc('')); + } + + $this->addStmt($method); + + return $this; + } + + /** + * @param list $phpstanType + * @param array $phpstanImports + */ + public function addJsonSerializeMethod(Schema $schema, string $subnamespace = '', array &$phpstanType = [], array &$phpstanImports = []): static + { + $factory = new BuilderFactory(); + + $method = $factory + ->method('jsonSerialize') + ->makePublic() + ->setReturnType('array') + ; + + $phpdoc = []; + $arrayItems = []; + + /** @var list $required */ + $required = $schema->type === 'array' ? $schema->items->required ?? [] : $schema->required ?? []; + /** @var array $properties */ + $properties = $schema->type === 'array' ? $schema->items->properties ?? [] : $schema->properties; + + foreach ($properties as $name => $property) { + $property = $this->schemaResolver->resolve($property); + + $isRequired = \in_array($name, $required, true); + $propertyName = Utils::camelize($name); + + $type = $this->typeResolver->resolve($property, $isRequired); + $arrayItem = new ArrayItem($factory->var("this->$propertyName"), $factory->val($propertyName)); + $arrayItems[] = $arrayItem; + $phpdoc[] = "$propertyName: {$type->phpdoc()}"; + $phpstanType[] = "$name: {$type->phpdoc(suffix: 'InputData')}"; + + if ($type->isValueObject()) { + $phpstanImports[$type->real()] = $type->real(suffix: 'InputData'); + $this->voBuilder->add($property, $type, $subnamespace); + } + } + + $method->addStmt(new Return_(new Array_($arrayItems))); + $method->setDocComment(new Doc("\n/**\n * @return array{\n * " . implode(",\n * ", $phpdoc) . ",\n * }\n */")); + + $this->addStmt($method); + + return $this; + } + + /** + * @return array{0: Param[], 1: string[]} + */ + private function buildConstructorParams(Schema $schema): array + { + $factory = new BuilderFactory(); + $params = []; + $phpdoc = []; + + /** @var list $required */ + $required = $schema->type === 'array' ? $schema->items->required ?? [] : $schema->required ?? []; + /** @var array $properties */ + $properties = $schema->type === 'array' ? $schema->items->properties ?? [] : $schema->properties; + + foreach ($properties as $name => $property) { + if ($property instanceof Reference) { + throw new \UnexpectedValueException('Cannot handle Reference type, only Schema type is supported!'); + } + + $isRequired = \in_array($name, $required, true); + $propertyName = Utils::camelize($name); + + $type = $this->typeResolver->resolve($property, $isRequired); + $phpdoc[] = " * @param {$type->phpdoc()} \$$propertyName"; + + $param = $factory->param($propertyName) + ->setType($type->php()) + ->makePublic() + ->makeReadonly() + ; + + // if (isset($property->default) && \is_scalar($property->default)) { + // $param->setDefault($factory->val($property->default)); + // } + + $params[] = $param; + } + + return [$params, $phpdoc]; + } +} diff --git a/src/BuilderInterface.php b/src/BuilderInterface.php new file mode 100644 index 0000000..e6901c5 --- /dev/null +++ b/src/BuilderInterface.php @@ -0,0 +1,22 @@ + true]); + $declare = new Declare_([new DeclareItem('strict_types', new Int_(1))]); + + foreach ($this->classes as $className => $class) { + $namespace = $this->factory + ->namespace("$baseNamespace\Client") + ->setDocComment(new Doc('')) + ->addStmt($this->factory->use('Psr\Http\Client\ClientExceptionInterface')) + ->addStmt($this->factory->use("$baseNamespace\Exception\ClientException")) + ->addStmt($this->factory->use("$baseNamespace\Exception\ComponentException")) + ->addStmt($this->factory->use("$baseNamespace\Formatter")) + ->addStmt($this->factory->use("$baseNamespace\VO")) + ->addStmt($this->factory->use('JsonException')) + ->addStmt($class); + $content = $prettyPrinter->prettyPrintFile([$declare, $namespace->getNode()]); + \file_put_contents($destination . "/Client/$className.php", $content); + } + + } + + public function add(string $path, PathItem $pathItem): void + { + $parts = \explode('/', \trim($path, '/'), 2); + $name = \count($parts) === 1 ? '' : Utils::pascalize($parts[0]); + + if (!isset($this->classes["{$name}Client"])) { + $class = $this->factory->class("{$name}Client") + ->extend('AbstractClient') + ->setDocComment(new Doc('')) + ; + } else { + $class = $this->classes["{$name}Client"]; + } + + foreach (OperationType::cases() as $operationType) { + if (!isset($pathItem->{$operationType->value})) { + continue; + } + + $operation = $pathItem->{$operationType->value}; + $class->addStmt($this->generateMethod($path, $operation, $operationType)); + } + + $this->classes["{$name}Client"] = $class; + } + + private function generateMethod(string $path, Operation $operation, OperationType $operationType): ClientMethod + { + $methodName = $this->getClientMethodName($operation); + + $pathParams = $this->generatePathParams($operation); + $bodyParams = $this->generateBodyParams($operation); + $queryParams = $this->generateQueryParams($operation); + + $schema = $this->apiSchema->getResponseSchema($operation, 200); + $schema = $this->schemaResolver->resolve($schema); + + $data = $this->apiSchema->getDataSchema($schema); + $data = $this->schemaResolver->resolve($data ?? $schema); + + $type = $this->typeResolver->resolve($data, true); + $phpdoc = $this->getPhpdocTypedArray($operation, $type); + + $this->formatterBuilder->add($data, $type); + $this->voBuilder->add($data, $type, subnamespace: 'VO'); + + return (new ClientMethod($methodName)) + ->makePublic() + ->addParams([...$pathParams, ...$bodyParams, ...$queryParams]) + ->setReturnType($type->return('VO')) + ->setDocComment($phpdoc) + //~ Specific client methods + ->addAssignEndpoint($path, $this->filterPathParams($operation)) + ->addCallBuilder($queryParams !== [], $bodyParams !== [], $operationType) + ->addReturnData($type) + ; + } + + /** + * @return list + */ + private function generatePathParams(Operation $operation): array + { + $params = []; + + foreach ($operation->parameters as $param) { + if ($param instanceof Reference || !$param->schema instanceof Schema || $param->in !== "path") { + continue; + } + + $schema = $this->schemaResolver->resolve($param->schema); + + $type = $this->typeResolver->resolve($schema, true); + $params[] = $this->factory + ->param($param->name) + ->setType($type->real()) + ; + } + + return $params; + } + + /** + * @return list + */ + private function generateQueryParams(Operation $operation): array + { + $queryParams = $this->filterQueryParams($operation); + if ($queryParams === []) { + return []; + } + + $param = $this->factory + ->param('query') + ->setType('array') + ->setDefault([]) + ; + + return [$param]; + } + + /** + * @return list + */ + private function generateBodyParams(Operation $operation): array + { + $bodySchema = $this->apiSchema->getBodySchema($operation); + + if ($bodySchema === null) { + return []; + } + + $bodySchema = $this->schemaResolver->resolve($bodySchema); + $bodyType = $this->typeResolver->resolve($bodySchema, true); + $param = $this->factory + ->param('body') + ->setType($bodyType->real('VO\\RequestBody')) + ; + + $this->voBuilder->add($bodySchema, $bodyType, subnamespace: 'VO\RequestBody'); + + return [$param]; + } + + /** + * @return list + */ + private function filterPathParams(Operation $operation): array + { + $params = []; + + foreach ($operation->parameters as $param) { + if ($param instanceof Reference || $param->in !== "path") { + continue; + } + + $params[] = $param; + } + + return $params; + } + + /** + * @return list + */ + private function filterQueryParams(Operation $operation): array + { + $params = []; + + foreach ($operation->parameters as $param) { + if ($param instanceof Reference || $param->in !== "query") { + continue; + } + + $params[] = $param; + } + + return $params; + } + + private function getPhpdocTypedArray(Operation $operation, Type $type): Doc + { + $phpdoc = [ + 'new' => '', + 'top' => '/**', + 'query' => ' * @param array{#ARRAY_TYPES#} $query', + 'return' => ' * @return ' . $type->phpdoc('VO'), + 'throws' => ' * @throws ClientException|ComponentException|ClientExceptionInterface|JsonException', + 'end' => ' */', + ]; + + $params = $this->filterQueryParams($operation); + if ($params !== []) { + $types = []; + foreach ($params as $param) { + if (!isset($param->schema) || $param->schema instanceof Reference) { + continue; + } + $schema = $this->schemaResolver->resolve($param->schema); + $paramType = $this->typeResolver->resolve($schema, $param->required); + $types[] = $param->name . (!$param->required ? '?:' : ':') . $paramType->real; + } + + $phpdoc['query'] = \str_replace('#ARRAY_TYPES#', \implode(', ', $types), $phpdoc['query']); + } else { + unset($phpdoc['query']); + } + + if (!$type->isArray()) { + unset($phpdoc['return']); // Remove useless phpdoc + } + + return new Doc(\implode("\n", $phpdoc)); + } + + private function getClientMethodName(Operation $operation): string + { + $name = $operation->summary !== '' && $operation->summary !== null ? $operation->summary : $operation->operationId; + $name = \ltrim($name, 'post'); + + return Utils::camelize($name); + } +} diff --git a/src/Config/Config.php b/src/Config/Config.php new file mode 100644 index 0000000..55ed56f --- /dev/null +++ b/src/Config/Config.php @@ -0,0 +1,76 @@ +, + * overrides?: array{ + * types?: array, + * schemas: array, + * }, + * custom?: array{ + * schemas?: array}>, + * }, + * } + */ +class Config +{ + /** + * @param list $ignorePaths + * @param array $overrideTypes + * @param array $overrideSchemas + * @param Schema[] $customSchemas + */ + public function __construct( + public readonly string $file, + public readonly string $namespace, + public readonly string $destination, + public readonly array $ignorePaths, + public readonly array $overrideTypes, + public readonly array $overrideSchemas, + public readonly array $customSchemas, + ) {} + + public function isIgnoredPath(string $path): bool + { + foreach ($this->ignorePaths as $ignorePath) { + if (\preg_match('`' . $ignorePath . '`', $path) === 1) { + return true; + } + } + + return false; + } + + public function overrideNativeType(string $name, string $type): string + { + return $this->overrideTypes[$name]['native'] ?? $type; + } + + public function overrideRealType(string $name, string $type): string + { + return $this->overrideTypes[$name]['real'] ?? $type; + } + + public function overrideSchema(Schema $schema): Schema + { + $name = $this->overrideSchemas[$schema->title ?? ''] ?? ''; + + return $this->customSchemas[$name] ?? $schema; + } +} diff --git a/src/DummySrc.php b/src/DummySrc.php deleted file mode 100644 index c4ff85e..0000000 --- a/src/DummySrc.php +++ /dev/null @@ -1,19 +0,0 @@ - */ + private array $formatters = []; + + /** @var string[][] */ + private array $formattersImplements = []; + + public function __construct( + private readonly BuilderFactory $factory, + private readonly VOBuilder $voBuilder, + private readonly SchemaResolver $schemaResolver, + private readonly TypeResolver $typeResolver, + ) {} + + public function generate(string $destination, string $baseNamespace): void + { + $prettyPrinter = new CustomStandard(['shortArraySyntax' => true, 'newMultiline' => true]); + $declare = new Declare_([new DeclareItem('strict_types', new Int_(1))]); + + foreach ($this->formatters as $className => ['class' => $class, 'type' => $type]) { + /** @var FormatterClass $class */ + $class + ->implement(...$this->formattersImplements[$className]) + ->setPhpDocWithGeneric($type, $this->formattersImplements[$className]) + ; + + $namespace = $this->factory->namespace("$baseNamespace\Formatter"); + + if ($type->isValueObject()) { + $namespace = $namespace->addStmt($this->factory->use("$baseNamespace\VO")); + } + + $namespace + ->setDocComment(new Doc('')) + ->addStmt($class) + ; + + $content = $prettyPrinter->prettyPrintFile([$declare, $namespace->getNode()]); + \file_put_contents("$destination/Formatter/$className.php", $content); + } + } + + public function add(Schema $schema, Type $type): void + { + $className = $type->formatter(); + + if (!isset($this->formatters[$className])) { + $class = new FormatterClass($className, $schema, $this, $this->voBuilder, $this->schemaResolver, $this->typeResolver); + $class = $this->generateMethods($class, $type); + + $this->formatters[$className] = ['class' => $class, 'type' => $type]; + } + + $implements = $type->isArray() ? 'ListFormatterInterface' : 'FormatterInterface'; + $this->formattersImplements[$className][$implements] = $implements; + } + + private function generateMethods(FormatterClass $class, Type $type): FormatterClass + { + $trait = new TraitUse([new Name('FormatterTrait')]); + $trait->setDocComment(new Doc("/** @use FormatterTrait<{$type->real('VO')}> */")); + + $class + ->addStmt($trait) + ->addFormatItemMethod($type) + ; + + return $class; + } +} diff --git a/src/Printer/CustomStandard.php b/src/Printer/CustomStandard.php new file mode 100644 index 0000000..7783044 --- /dev/null +++ b/src/Printer/CustomStandard.php @@ -0,0 +1,109 @@ +isArrayMultiline = $options['arrayMultiline'] ?? false; + $this->isMethodMultiline = $options['methodMultiline'] ?? false; + $this->isNewMultiline = $options['newMultiline'] ?? false; + } + + /** + * @throws \Exception + */ + protected function pScalar_String(Scalar\String_ $node): string + { + $kind = $node->getAttribute('kind', Scalar\String_::KIND_SINGLE_QUOTED); + $noEscape = $node->getAttribute('noEscape', false); + + if ($kind === Scalar\String_::KIND_DOUBLE_QUOTED && $noEscape === true) { + return '"' . $node->value . '"'; + } + + return parent::pScalar_String($node); + } + protected function pExpr_Array(Expr\Array_ $node): string + { + if (!$this->isArrayMultiline) { + return parent::pExpr_Array($node); + } + + //~ When line is to long, force multiline + $syntax = $node->getAttribute( + 'kind', + $this->shortArraySyntax ? Expr\Array_::KIND_SHORT : Expr\Array_::KIND_LONG, + ); + + if ($syntax === Expr\Array_::KIND_SHORT) { + $line = '[' . $this->pCommaSeparatedMultiline($node->items, true) . $this->nl . ']'; + } else { + $line = 'array(' . $this->pCommaSeparatedMultiline($node->items, true) . ')'; + } + + return $line; + } + + protected function pStmt_ClassMethod(Stmt\ClassMethod $node): string + { + if (!$this->isMethodMultiline) { + return parent::pStmt_ClassMethod($node); + } + + return $this->pAttrGroups($node->attrGroups) + . $this->pModifiers($node->flags) + . 'function ' . ($node->byRef ? '&' : '') . $node->name + . '(' . $this->pCommaSeparatedMultiline($node->params, true) . ($node->params !== [] ? $this->nl : '') . ')' + . (null !== $node->returnType ? ' : ' . $this->p($node->returnType) : '') + . (null !== $node->stmts + ? $this->nl . '{' . $this->pStmts($node->stmts) . ($node->stmts !== [] ? $this->nl : '') . '}' + : ';'); + } + + protected function pExpr_New(Expr\New_ $node): string + { + if (!$this->isNewMultiline) { + return parent::pExpr_New($node); + } + + if ($node->class instanceof Stmt\Class_) { + $args = $node->args !== [] ? '(' . $this->pCommaSeparatedMultiline($node->args, true) . $this->nl . ')' : ''; + return 'new ' . $this->pClassCommon($node->class, $args); + } + return 'new ' . $this->pNewOperand($node->class) + . '(' . $this->pCommaSeparatedMultiline($node->args, true) . ($node->args !== [] ? $this->nl : '') . ')'; + } +} diff --git a/src/Schema/ApiSchema.php b/src/Schema/ApiSchema.php new file mode 100644 index 0000000..81c4f5a --- /dev/null +++ b/src/Schema/ApiSchema.php @@ -0,0 +1,54 @@ +responses[(string) $code] ?? null; + + if ($response === null) { + throw new \UnexpectedValueException("No response for code '$code', cannot process endpoint !"); + } + + $schema = $response->content['application/json']->schema ?? $response->content['text/plain']->schema ?? null; + + if ($schema === null) { + throw new \UnexpectedValueException("No schema found in response ! (tried for application/json and text/plain)"); + } + + if ($schema instanceof Reference) { + throw new \UnexpectedValueException("Schema 'Reference' type not supported !"); + } + + return $schema; + } + + public function getBodySchema(Operation $operation): ?Schema + { + /** @var Schema|Reference|null $body */ + $body = $operation->requestBody->content['application/json']->schema ?? null; + + if ($body instanceof Reference) { + $body = null; + } + + return $body; + } +} diff --git a/src/Schema/JsonApiSchema.php b/src/Schema/JsonApiSchema.php new file mode 100644 index 0000000..a28fc16 --- /dev/null +++ b/src/Schema/JsonApiSchema.php @@ -0,0 +1,63 @@ +oneOf ?? null)) { + foreach ($schema->oneOf as $oneSchema) { + if (!($oneSchema instanceof Schema)) { + continue; + } + + if (isset($oneSchema->properties['data'])) { + $schema = $oneSchema; + break; + } + } + } + + $data = $schema->properties['data'] ?? null; + + if (\is_array($data->allOf ?? null)) { + $data = $data->allOf[0] ?? null; + } + + return $data instanceof Schema ? $data : null; + } + + public function getErrorSchema(Schema $schema): ?Schema + { + //~ Handle oneOf list of sub-schema + if (\is_array($schema->oneOf ?? null)) { + foreach ($schema->oneOf as $oneSchema) { + if (!($oneSchema instanceof Schema)) { + continue; + } + + if (isset($oneSchema->properties['errors'])) { + $schema = $oneSchema; + break; + } + } + } + + $data = $schema->properties['errors'] ?? null; + + return $data instanceof Schema ? $data : null; + } +} diff --git a/src/Schema/SchemaResolver.php b/src/Schema/SchemaResolver.php new file mode 100644 index 0000000..4f53acb --- /dev/null +++ b/src/Schema/SchemaResolver.php @@ -0,0 +1,34 @@ +allOf) && \is_array($schema->allOf)) { + $schema = $this->resolve($schema->allOf[0] ?? null); + } + + return $this->config->overrideSchema($schema); + } +} diff --git a/src/Type/Type.php b/src/Type/Type.php new file mode 100644 index 0000000..a7be193 --- /dev/null +++ b/src/Type/Type.php @@ -0,0 +1,84 @@ +native) { + 'string', 'int', 'float', 'bool' => true, + default => false, + }; + } + + public function isArray(): bool + { + return $this->native === 'array'; + } + + public function isValueObject(): bool + { + $type = $this->subType === null ? $this->real : $this->subType->real; + + return match ($type) { + 'string', 'int', 'float', 'bool', 'array', 'object' => false, + default => true, + }; + } + + public function formatter(): string + { + return ucfirst($this->subType === null ? $this->real : $this->subType->real) . 'Formatter'; + } + + public function real(string $namespace = '', string $suffix = ''): string + { + $type = $this->subType === null ? $this->real : $this->subType->real; + + return match ($type) { + 'string', 'int', 'float', 'bool', 'array', 'object' => $type, + default => \trim(\trim($namespace, '\\') . '\\' . $type . $suffix, '\\'), + }; + } + + public function return(string $namespace = ''): string + { + return $this->isArray() ? $this->native : $this->real($namespace); + } + + public function php(string $namespace = ''): string + { + $type = $this->subType === null ? $this : $this->subType; + $real = $this->native === 'array' ? $this->native : $type->real($namespace); + + return ($this->nullable ? '?' : '') . $real; + } + + public function phpdoc(string $namespace = '', string $suffix = ''): string + { + return ($this->nullable ? '?' : '') . $this->real($namespace, $suffix) . ($this->isArray() ? '[]' : ''); + } + + public function __toString(): string + { + return $this->php() . ' | ' . $this->phpdoc() . ' | ' . $this->real() . ' | ' . $this->native; + } +} diff --git a/src/Type/TypeResolver.php b/src/Type/TypeResolver.php new file mode 100644 index 0000000..2ca1859 --- /dev/null +++ b/src/Type/TypeResolver.php @@ -0,0 +1,100 @@ +oneOf)) { + $types = $this->resolveOneOf($schema, $isRequired); + if (count($types) === 1) { + return $types[0]; + } + + //throw new \UnexpectedValueException('TODO: Handle multiple types for schema: ' . ($schema->title ?? ' unknown')); + } + + if (isset($schema->allOf)) { + if ($schema->allOf[0] instanceof Schema) { + return $this->resolve($schema->allOf[0], $isRequired); + } + + throw new \UnexpectedValueException('allOf without Schema type is not supported for schema: ' . ($schema->title ?? ' unknown')); + } + + $type = $schema->type ?? ''; + if ($type === '') { + $type = 'string'; + echo 'Missing type for schema: ' . ($schema->title ?? ' unknown') . ', defaulting to string' . "\n"; + //throw new \UnexpectedValueException('No type specified for schema: ' . ($schema->title ?? ' unknown')); + } + + $native = match ($type) { + 'integer' => 'int', + 'number' => 'float', + 'boolean' => 'bool', + default => $type, + }; + + $title = isset($schema->title) ? \str_replace([' ', '-', '_'], '', \ucwords($schema->title, " -_")) : null; + $name = match ($native) { + 'string', 'int', 'float', 'bool', 'array' => $native, + default => $title ?? $native, + }; + + $native = $this->config->overrideNativeType($name, $native); + $real = $this->config->overrideRealType($name, $name); + + $nullable = !$isRequired; + $default = isset($schema->default) && \is_scalar($schema->default) ? $schema->default : null; + $subType = null; + + if ($native === 'array' && isset($schema->items) && $schema->items instanceof Schema) { + $subType = $this->resolve($schema->items, true); + $real = $subType->real; + } + + return new Type( + $native, + $real, + $nullable, + $default, + $subType, + ); + } + + /** + * @return list + */ + private function resolveOneOf(Schema $schema, bool $isRequired): array + { + $types = []; + foreach ($schema->oneOf as $oneOf) { + if ($oneOf instanceof Schema) { + $type = $this->resolve($oneOf, $isRequired); + $types["$type->native.$type->real"] = $type; + } + } + + return \array_values($types); + } +} diff --git a/src/Utils/Utils.php b/src/Utils/Utils.php new file mode 100644 index 0000000..ba35015 --- /dev/null +++ b/src/Utils/Utils.php @@ -0,0 +1,35 @@ + */ + private array $classes = []; + + public function __construct( + private readonly BuilderFactory $factory, + private readonly SchemaResolver $schemaResolver, + private readonly TypeResolver $typeResolver, + ) {} + + public function generate(string $destination, string $baseNamespace): void + { + $prettyPrinter = new CustomStandard(['shortArraySyntax' => true, 'arrayMultiline' => true, 'methodMultiline' => true]); + $declare = new Declare_([new DeclareItem('strict_types', new Int_(1))]); + + foreach ($this->classes as ['className' => $className, 'subnamespace' => $subnamespace, 'class' => $class]) { + $path = $this->initDirectory($destination, $subnamespace); + + $namespace = $this->factory + ->namespace("$baseNamespace\\$subnamespace") + ->addStmt($this->factory->use('Eureka\Component\Serializer\JsonSerializableTrait')) + ->addStmt($this->factory->use('JsonSerializable')) + ->setDocComment(new Doc('')) + ->addStmt($class); + $content = $prettyPrinter->prettyPrintFile([$declare, $namespace->getNode()]); + \file_put_contents("$path/$className.php", $content); + } + } + + public function add(Schema $schema, Type $type, string $subnamespace = ''): void + { + if (!$type->isValueObject()) { + return; + } + + if (isset($this->classes[$type->real($subnamespace)])) { + return; + } + + $phpstanType = []; + $phpstanImports = []; + $class = (new VOClass($type->real, $this, $this->schemaResolver, $this->typeResolver)) + ->implement('JsonSerializable') + ->addStmt($this->factory->useTrait('JsonSerializableTrait')) + ->addConstructor($schema) + ->addJsonSerializeMethod($schema, $subnamespace, $phpstanType, $phpstanImports) + ; + + $doc = new Doc(''); + if ($phpstanType !== []) { + $phpstanTypeDoc = " * @phpstan-type {$type->real(suffix: 'InputData')} object{" . \implode(', ', $phpstanType) . '}&\stdClass'; + $phpstanImportsDoc = []; + foreach ($phpstanImports as $from => $phpstanImport) { + $phpstanImportsDoc[] = " * @phpstan-import-type $phpstanImport from $from"; + } + $importDoc = ($phpstanImportsDoc !== [] ? "\n" : '') . implode("\n", $phpstanImportsDoc); + $doc = new Doc("\n/**$importDoc\n$phpstanTypeDoc\n */"); + } + + + $class->setDocComment($doc); + + $this->classes[$type->real($subnamespace)] = ['className' => $type->real, 'subnamespace' => $subnamespace, 'class' => $class]; + } + + private function initDirectory(string $destination, string $namespace): string + { + $subpath = str_replace('\\', '/', $namespace); + + if (!\is_dir("$destination/$subpath") && !\mkdir("$destination/$subpath", 0755, true)) { + throw new \UnexpectedValueException("Failed to create directory: $destination/$subpath"); + } + + return "$destination/$subpath"; + } +}