diff --git a/.gitattributes b/.gitattributes index 59a302b..2436a99 100644 --- a/.gitattributes +++ b/.gitattributes @@ -10,4 +10,5 @@ /CHANGELOG.md -export-ignore /composer.json -export-ignore /LICENSE -export-ignore +/map.json -export-ignore /README.md -export-ignore diff --git a/README.md b/README.md index 2414fa9..4a24b4e 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ For this reason, we have written this plugin, which — in addition to addressin - [memory limit](#memory-limit) - [metadata limit](#metadata) - [multiple options](#multiple-options) +- [Namespace mapping](#namespace-mapping) - [Generated code guide](#generated-code-guide) - [numbers](#numbers) - [repeated](#repeated) @@ -242,6 +243,56 @@ protoc \ --php-plugin_out=genproto ``` +### Namespace mapping + +Thesis is an alternative runtime, therefore its generated code must be able to live in the same project as the code +generated by the standard `google/protobuf` runtime. That is impossible as long as both generate, say, `Google\Protobuf\Any`: +the class names collide, which is why `thesis/protobuf-known-types` used to declare `conflict: google/protobuf` — and that +conflict, in turn, made it impossible to use packages tied to `google/protobuf` (`open-telemetry/*`, for instance). + +To solve this, the plugin relocates well-known packages into namespaces of its own. The relocation table is the +[`map.json`](map.json) file shipped with the plugin (it is bundled into the phar and into the docker image, so it applies no +matter how the plugin is installed): + +```json +{ + "rules": [ + {"from": "google.protobuf.*", "to": "thesis.google.protobuf.*"}, + {"from": "google.rpc.Status", "to": "thesis.google.rpc.Status"} + ] +} +``` + +A rule is written in terms of protobuf names, not PHP ones, the PHP namespace is derived from the rewritten name exactly +the same way it is derived from a package: + +| `from` | `to` | effect | +|---------------------|----------------------------|---------------------------------------------------------------------------------------------------------------| +| `google.protobuf.*` | `thesis.google.protobuf.*` | pattern: the package itself and everything nested in it, `Google\Protobuf\Any` → `Thesis\Google\Protobuf\Any` | +| `google.rpc.Status` | `thesis.google.rpc.Status` | exact: one type only, `Google\Rpc\Status` → `Thesis\Google\Rpc\Status` | + +* both sides of a rule must be of the same kind: either both end with `.*`, or neither does; +* an exact rule wins over a pattern, and a longer pattern wins over a shorter one; +* names are rewritten in one pass — the result of a rule is never fed into another rule; +* a rule may only move a type, not rename it: the last segment of `from` and `to` must be equal; +* a rule wins over the `php_namespace` option, both the one from the schema and the one passed on the command line — + a type is relocated precisely because its default namespace is already taken. + +The rewriting never leaves PHP. Protobuf names themselves stay exactly as the schema declares them, so +`DescriptorRegistry` keeps registering `google.protobuf.Timestamp`: + +```php +$pool->add(Registry\Descriptor::base64(self::TIMESTAMP_DESCRIPTOR_BUFFER), new File( + name: 'google/protobuf/timestamp.proto', + messages: [ + new File\MessageDescriptor('google.protobuf.Timestamp', \Thesis\Google\Protobuf\Timestamp::class), + ], +)); +``` + +That is what keeps `Any` type urls and server reflection interoperable: other languages know nothing about +`thesis.google.protobuf.Timestamp`. + ### Generated code guide As mentioned above, the plugin generates simple DTOs without setters, getters, or inheritance. All metadata for protobuf serialization is stored in attribute `Thesis\Protobuf\Reflection\*`, and the generated DTOs only have a constructor with promoted properties. diff --git a/bin/compiler.php b/bin/compiler.php index febf52a..b5962be 100755 --- a/bin/compiler.php +++ b/bin/compiler.php @@ -26,7 +26,7 @@ $decoder = Decoder\Builder::buildDefault(); $entrypoint = new Protoc\Entrypoint( - new Plugin\Compiler($encoder), + new Plugin\Compiler($encoder, __DIR__ . '/../map.json'), $encoder, $decoder, ); diff --git a/box.json b/box.json index 94918ab..ac53170 100644 --- a/box.json +++ b/box.json @@ -5,6 +5,9 @@ "src", "vendor" ], + "files": [ + "map.json" + ], "main": "bin/compiler.php", "output": "protoc-gen-php", "shebang": "#!/usr/bin/env php" diff --git a/map.json b/map.json new file mode 100644 index 0000000..35ece81 --- /dev/null +++ b/map.json @@ -0,0 +1,11 @@ +{ + "rules": [ + {"from": "google.protobuf.*", "to": "thesis.google.protobuf.*"}, + {"from": "google.rpc.*", "to": "thesis.google.rpc.*"}, + {"from": "google.api.*", "to": "thesis.google.api.*"}, + {"from": "google.type.*", "to": "thesis.google.type.*"}, + {"from": "grpc.health.v1.*", "to": "thesis.grpc.health.v1.*"}, + {"from": "grpc.reflection.v1.*", "to": "thesis.grpc.reflection.v1.*"}, + {"from": "grpc.reflection.v1alpha.*", "to": "thesis.grpc.reflection.v1alpha.*"} + ] +} diff --git a/src/Exception/InvalidNamespaceMapping.php b/src/Exception/InvalidNamespaceMapping.php new file mode 100644 index 0000000..cbde290 --- /dev/null +++ b/src/Exception/InvalidNamespaceMapping.php @@ -0,0 +1,12 @@ +grpc = new Generator\GrpcGenerator( $namespacer, $graph, diff --git a/src/Plugin/Compiler.php b/src/Plugin/Compiler.php index c973850..d4e1983 100644 --- a/src/Plugin/Compiler.php +++ b/src/Plugin/Compiler.php @@ -15,7 +15,6 @@ use Thesis\Protoc\Plugin\Generator\DescriptorMetadataRegistryGenerator; use Thesis\Protoc\Plugin\Generator\FileFactory; use Thesis\Protoc\Plugin\Generator\PhpNamespacer; -use Thesis\Protoc\Plugin\Parser\FileDescriptor; use Thesis\Protoc\Plugin\Parser\MessageDescriptor; use Thesis\Protoc\Plugin\Parser\ServiceMethodDescriptor; use Thesis\Protoc\ProtocException; @@ -29,8 +28,12 @@ private Parser $parser; + /** + * @param non-empty-string $mappingPath + */ public function __construct( private Encoder $encoder, + private string $mappingPath, ) { $this->parser = new Parser(); } @@ -58,16 +61,20 @@ private function doGenerate( CodeGeneratorRequest $request, CompilerOptions $options, ): iterable { + $types = Mapping\TypeMap::fromFile($this->mappingPath); + $request = $this->parser->parse($request); - $registry = new Dependency\Registry($request, $options); + $namespaces = new NamespaceResolver($types, $options); + + $registry = new Dependency\Registry($request, $namespaces); $descriptorPaths = new PathTable(); $registries = new DescriptorTable(); foreach ($request as $source => $proto) { - $phpNamespace = self::determinePhpNamespace($proto, $options); + $phpNamespace = $namespaces->file($proto) ?? throw self::namespaceCannotBeDetermined(); $index = new NameIndex(); @@ -78,12 +85,14 @@ private function doGenerate( files: new FileFactory( self::createClassLikeGeneratedDoc($request, $source), $path, + $phpNamespace, ), graph: $registry->graph($source), index: $index, package: $proto->package, syntax: $proto->syntax, edition: $proto->file->edition, + types: $types, ); foreach ($proto->services as $service) { @@ -185,30 +194,9 @@ private function doGenerateMessages(ClassLikeGenerator $generator, MessageDescri } } - /** - * @return non-empty-string - * @throws CodeCannotBeGenerated - */ - private static function determinePhpNamespace( - FileDescriptor $descriptor, - CompilerOptions $options, - ): string { - if ($options->phpNamespace !== null) { - return $options->phpNamespace; - } - - $phpNamespace = $descriptor->options?->phpNamespace; - if ($phpNamespace !== null && $phpNamespace !== '') { - return $phpNamespace; - } - - $package = $descriptor->package; - if ($package !== null && $package !== '') { - /** @var non-empty-string */ - return Naming::joinNamespace(explode('.', $package)); - } - - throw new CodeCannotBeGenerated('neither "package" nor "php_namespace" option was specified in the provided proto files, therefore I cannot determine the namespace under which the PHP files should be created. + private static function namespaceCannotBeDetermined(): CodeCannotBeGenerated + { + return new CodeCannotBeGenerated('neither "package" nor "php_namespace" option was specified in the provided proto files, therefore I cannot determine the namespace under which the PHP files should be created. If you cannot modify the proto files, please pass the namespace via command-line arguments as follows: --custom-plugin_out=php_namespace=App\\\Service\\\V1:path/to/generated'); } diff --git a/src/Plugin/Dependency/Registry.php b/src/Plugin/Dependency/Registry.php index 6a456ff..0fef475 100644 --- a/src/Plugin/Dependency/Registry.php +++ b/src/Plugin/Dependency/Registry.php @@ -4,7 +4,7 @@ namespace Thesis\Protoc\Plugin\Dependency; -use Thesis\Protoc\Plugin\CompilerOptions; +use Thesis\Protoc\Plugin\NamespaceResolver; use Thesis\Protoc\Plugin\Naming; use Thesis\Protoc\Plugin\Parser; @@ -22,9 +22,11 @@ final class Registry /** @var array> */ private array $fileIndexes = []; - public function __construct(Parser\Request $request, CompilerOptions $options) - { - $this->createIndex($request, $options); + public function __construct( + Parser\Request $request, + private readonly NamespaceResolver $namespaces, + ) { + $this->createIndex($request, $namespaces); $this->mergeIndex($request); $this->createDependencyGraph($request); } @@ -34,7 +36,7 @@ public function graph(string $name): Graph return $this->graph[$name] ?? throw new \RuntimeException("Graph for file {$name} does not exist."); } - private function createIndex(Parser\Request $request, CompilerOptions $options): void + private function createIndex(Parser\Request $request, NamespaceResolver $namespaces): void { foreach ($request->descriptors as $name => $descriptor) { $types = iterator_to_array( @@ -45,7 +47,7 @@ private function createIndex(Parser\Request $request, CompilerOptions $options): ); $package = $descriptor->package ?? '.'; - $namespace = $options->phpNamespace ?? $descriptor->options->phpNamespace ?? Naming::namespace($package); + $namespace = $namespaces->file($descriptor) ?? Naming::namespace($package); $this->index[$name] = new Index( $types, @@ -192,6 +194,10 @@ private function extractTypes(array $typeNames, string $proto): iterable $class = Naming::namespace(substr($typeName, \strlen($prefix))); $fqcn = "\\{$index->namespace}\\{$class}"; + // A type relocated by an exact rule leaves the namespace of its own + // package behind, so its name is resolved from the rule alone. + $fqcn = $this->namespaces->type($typeName) ?? $fqcn; + yield $typeName => new Type( $fqcn, $class, diff --git a/src/Plugin/Generator/FileFactory.php b/src/Plugin/Generator/FileFactory.php index 37daa0c..57a245b 100644 --- a/src/Plugin/Generator/FileFactory.php +++ b/src/Plugin/Generator/FileFactory.php @@ -18,9 +18,14 @@ { private PsrPrinter $printer; + /** + * @param string $path directory the $namespace maps onto + * @param ?string $namespace namespace the file the code belongs to is generated into + */ public function __construct( private string $generatedDoc, private string $path, + private ?string $namespace = null, ) { $this->printer = new Printer()->setTypeResolving(false); } @@ -52,8 +57,30 @@ public function create( }; return new CodeGeneratorResponse\File( - name: \sprintf('%s/%s.php', $this->path, $code instanceof PhpNamespace ? Naming::path($path) : $path), + name: $code instanceof PhpNamespace + ? $this->resolvePath($code->getName(), $path) + : \sprintf('%s/%s.php', $this->path, $path), content: $content, ); } + + /** + * Types normally live under the namespace of their file, so the path they are written + * to is the one configured for that file. A type moved elsewhere by a relocation rule + * no longer belongs there and is written under its own namespace instead. + */ + private function resolvePath(string $namespace, string $path): string + { + if ($this->namespace === null || $namespace === $this->namespace || str_starts_with($namespace, "{$this->namespace}\\")) { + return \sprintf('%s/%s.php', $this->path, Naming::path($path)); + } + + $paths = explode('.', $path); + + return \sprintf( + '%s/%s.php', + str_replace('\\', '/', $namespace), + Naming::pascalCase($paths[\count($paths) - 1]), + ); + } } diff --git a/src/Plugin/Generator/PhpNamespacer.php b/src/Plugin/Generator/PhpNamespacer.php index 7e302aa..5a6812c 100644 --- a/src/Plugin/Generator/PhpNamespacer.php +++ b/src/Plugin/Generator/PhpNamespacer.php @@ -5,6 +5,7 @@ namespace Thesis\Protoc\Plugin\Generator; use Nette\PhpGenerator\PhpNamespace; +use Thesis\Protoc\Plugin\Mapping\TypeMap; use Thesis\Protoc\Plugin\Naming; /** @@ -14,21 +15,58 @@ { public function __construct( public string $namespace, + private TypeMap $types = new TypeMap(), + private ?string $package = null, ) {} public function create(string $path): PhpNamespace { - $namespace = $this->namespace; + return new PhpNamespace($this->resolve($path)); + } + + public function fqcn(string $path): string + { + $paths = explode('.', $path); + $name = $paths[\count($paths) - 1]; + + return Naming::joinNamespace([ + '', + $this->resolve($path), + $name, + ]); + } + + private function resolve(string $path): string + { + $rewritten = $this->rewrite($path); + + if ($rewritten !== null) { + $segments = explode('.', $rewritten); + array_pop($segments); + + return Naming::joinNamespace($segments); + } $paths = explode('.', $path); $typeNamespace = \array_slice($paths, 0, \count($paths) - 1); + if (\count($typeNamespace) > 0) { - $namespace = Naming::joinNamespace([ - $namespace, + return Naming::joinNamespace([ + $this->namespace, ...$typeNamespace, ]); } - return new PhpNamespace($namespace); + return $this->namespace; + } + + /** + * @return ?non-empty-string + */ + private function rewrite(string $path): ?string + { + $type = $this->package !== null && $this->package !== '' ? "{$this->package}.{$path}" : $path; + + return $this->types->rewriteType($type); } } diff --git a/src/Plugin/Generator/ProtoGenerator.php b/src/Plugin/Generator/ProtoGenerator.php index c20761c..2972da2 100644 --- a/src/Plugin/Generator/ProtoGenerator.php +++ b/src/Plugin/Generator/ProtoGenerator.php @@ -29,13 +29,13 @@ private TypeDeclarationFactory $types; public function __construct( - Dependency\Graph $graph, + private Dependency\Graph $graph, private NameIndex $index, private PhpNamespacer $namespacer, private ?string $syntax = null, private ?Edition $edition = null, ) { - $this->types = new TypeDeclarationFactory($graph); + $this->types = new TypeDeclarationFactory($this->graph); } public function generateEnum(Parser\EnumDescriptor $enum): PhpNamespace @@ -298,23 +298,18 @@ private function generateMessage(PhpNamespace $namespace, string $className, Par $constructor ->addPromotedParameter(Naming::propertyName($oneOf->name)) - ->setType(Naming::joinNamespace([ - '', - $this->namespacer->namespace, - $message->path, - $oneOfName, - ])) + ->setType($this->namespacer->fqcn("{$message->path}.{$oneOfName}")) ->setNullable() ->setDefaultValue(null) ->addAttribute('Reflection\OneOf', [ array_map( fn(Parser\FieldDescriptor $variant) => new Literal( - Naming::joinNamespace([ - '', - $this->namespacer->namespace, - $message->path, - \sprintf('%s::class', self::oneofVariantName($oneOfName, $variant)), - ]), + \sprintf( + '%s::class', + $this->namespacer->fqcn( + "{$message->path}." . self::oneofVariantName($oneOfName, $variant), + ), + ), ), $variants, ), @@ -413,12 +408,7 @@ private function generateOneofVariant( Naming::pascalCase($className), $descriptor, [ - Naming::joinNamespace([ - '', - $this->namespacer->namespace, - $message->path, - $interfaceName, - ]), + $this->namespacer->fqcn("{$message->path}.{$interfaceName}"), ], ); } @@ -429,7 +419,9 @@ private function parseDefaultValue( ?string $typename = null, ): mixed { if ($type === FieldDescriptorProto\Type::TYPE_ENUM && $typename !== null) { - return new Literal(\sprintf("%s::{$defaultValue}", Naming::namespace($typename))); + // Resolved through the graph, so that a relocated enum is referenced + // under the very namespace it is generated into. + return new Literal(\sprintf("%s::{$defaultValue}", $this->graph->get($typename)->fqcn)); } return match ($type) { diff --git a/src/Plugin/Mapping/ExactRule.php b/src/Plugin/Mapping/ExactRule.php new file mode 100644 index 0000000..fade452 --- /dev/null +++ b/src/Plugin/Mapping/ExactRule.php @@ -0,0 +1,46 @@ +from; + } + + #[\Override] + public function rewriteType(string $type): ?string + { + return $type === $this->from ? $this->to : null; + } + + #[\Override] + public function rewritePackage(string $package): ?string + { + return null; + } + + #[\Override] + public function priority(): int + { + return 2 * \strlen($this->from) + 1; + } +} diff --git a/src/Plugin/Mapping/PatternRule.php b/src/Plugin/Mapping/PatternRule.php new file mode 100644 index 0000000..d5ff563 --- /dev/null +++ b/src/Plugin/Mapping/PatternRule.php @@ -0,0 +1,68 @@ +from . self::SUFFIX; + } + + #[\Override] + public function rewriteType(string $type): ?string + { + return $this->rewrite($type); + } + + #[\Override] + public function rewritePackage(string $package): ?string + { + return $this->rewrite($package); + } + + #[\Override] + public function priority(): int + { + return 2 * \strlen($this->from); + } + + /** + * @return ?non-empty-string + */ + private function rewrite(string $name): ?string + { + if ($name === $this->from) { + return $this->to; + } + + if (str_starts_with($name, "{$this->from}.")) { + return $this->to . substr($name, \strlen($this->from)); + } + + return null; + } +} diff --git a/src/Plugin/Mapping/Rule.php b/src/Plugin/Mapping/Rule.php new file mode 100644 index 0000000..2b2022c --- /dev/null +++ b/src/Plugin/Mapping/Rule.php @@ -0,0 +1,44 @@ + */ + public array $rules; + + /** + * @param list $rules + */ + public function __construct(array $rules = []) + { + usort($rules, static fn(Rule $a, Rule $b): int => $b->priority() <=> $a->priority()); + + $this->rules = $rules; + } + + /** + * @throws InvalidNamespaceMapping + */ + public static function fromFile(string $file): self + { + $json = @file_get_contents($file); + + if ($json === false) { + throw new InvalidNamespaceMapping("Namespace mapping file '{$file}' cannot be read."); + } + + return self::fromJson($json, $file); + } + + /** + * @throws InvalidNamespaceMapping + */ + public static function fromJson(string $json, string $source): self + { + try { + $mapping = json_decode($json, associative: true, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new InvalidNamespaceMapping("Namespace mapping '{$source}' is not a valid json: {$e->getMessage()}.", previous: $e); + } + + if (!\is_array($mapping) || array_is_list($mapping)) { + throw new InvalidNamespaceMapping("Namespace mapping '{$source}' must be a json object."); + } + + $rules = $mapping['rules'] ?? []; + + if (!\is_array($rules) || !array_is_list($rules)) { + throw new InvalidNamespaceMapping("Namespace mapping '{$source}' must contain a list of rules under the 'rules' key."); + } + + $parsed = []; + $seen = []; + + foreach ($rules as $index => $rule) { + $parsed[] = $parsedRule = self::parseRule($rule, $source, $index); + + $declaration = $parsedRule->declaration(); + + if (isset($seen[$declaration])) { + throw new InvalidNamespaceMapping("Namespace mapping '{$source}' declares '{$declaration}' more than once."); + } + + $seen[$declaration] = true; + } + + return new self($parsed); + } + + /** + * @param string $type fully qualified protobuf type name, with or without the leading dot + * @return ?non-empty-string rewritten name or null when no rule matches + */ + public function rewriteType(string $type): ?string + { + $name = ltrim($type, '.'); + + if ($name === '') { + return null; + } + + foreach ($this->rules as $rule) { + $rewritten = $rule->rewriteType($name); + + if ($rewritten !== null) { + return $rewritten; + } + } + + return null; + } + + /** + * @return ?non-empty-string rewritten package or null when no rule matches + */ + public function rewritePackage(string $package): ?string + { + foreach ($this->rules as $rule) { + $rewritten = $rule->rewritePackage($package); + + if ($rewritten !== null) { + return $rewritten; + } + } + + return null; + } + + /** + * @throws InvalidNamespaceMapping + */ + private static function parseRule(mixed $rule, string $source, int|string $index): Rule + { + $at = "rule #{$index} of namespace mapping '{$source}'"; + + if (!\is_array($rule)) { + throw new InvalidNamespaceMapping("The {$at} must be a json object with 'from' and 'to' keys."); + } + + if (($unknown = array_diff(array_keys($rule), ['from', 'to'])) !== []) { + throw new InvalidNamespaceMapping(\sprintf('The %s contains unknown keys: %s.', $at, implode(', ', array_map(strval(...), $unknown)))); + } + + $from = self::parseName($rule['from'] ?? null, 'from', $at); + $to = self::parseName($rule['to'] ?? null, 'to', $at); + + $fromIsPattern = str_ends_with($from, self::PATTERN_SUFFIX); + $toIsPattern = str_ends_with($to, self::PATTERN_SUFFIX); + + if ($fromIsPattern !== $toIsPattern) { + throw new InvalidNamespaceMapping("The {$at} mixes a pattern with an exact name: '{$from}' → '{$to}'. Either both or neither must end with '" . self::PATTERN_SUFFIX . "'."); + } + + if ($fromIsPattern && $toIsPattern) { + return new PatternRule( + self::validateName(substr($from, 0, -\strlen(self::PATTERN_SUFFIX)), 'from', $at), + self::validateName(substr($to, 0, -\strlen(self::PATTERN_SUFFIX)), 'to', $at), + ); + } + + $fromName = self::validateName($from, 'from', $at); + $toName = self::validateName($to, 'to', $at); + + // Only the namespace of a type may be rewritten. Renaming a type would mean + // that the generated class no longer matches the descriptor it is registered + // with, so we reject it instead of generating code nobody can use. + if (self::lastSegment($fromName) !== self::lastSegment($toName)) { + throw new InvalidNamespaceMapping("The {$at} renames a type: '{$fromName}' → '{$toName}'. Only the namespace a type lives in can be rewritten."); + } + + return new ExactRule($fromName, $toName); + } + + /** + * @return non-empty-string + * @throws InvalidNamespaceMapping + */ + private static function parseName(mixed $name, string $key, string $at): string + { + if (!\is_string($name) || $name === '') { + throw new InvalidNamespaceMapping("The {$at} must contain a non-empty string under the '{$key}' key."); + } + + return $name; + } + + /** + * @return non-empty-string + * @throws InvalidNamespaceMapping + */ + private static function validateName(string $name, string $key, string $at): string + { + $segments = explode('.', $name); + + foreach ($segments as $segment) { + if (preg_match(self::SEGMENT_PATTERN, $segment) !== 1) { + throw new InvalidNamespaceMapping("The '{$key}' name '{$name}' of the {$at} is not a valid protobuf name."); + } + } + + \assert($name !== ''); + + return $name; + } + + private static function lastSegment(string $name): string + { + $segments = explode('.', $name); + + return $segments[\count($segments) - 1]; + } +} diff --git a/src/Plugin/NamespaceResolver.php b/src/Plugin/NamespaceResolver.php new file mode 100644 index 0000000..82e9531 --- /dev/null +++ b/src/Plugin/NamespaceResolver.php @@ -0,0 +1,76 @@ +package; + + // A relocation rule wins over any php_namespace: types are moved exactly because + // their default namespace is already occupied by another runtime. + if ($package !== null && $package !== '') { + $rewritten = $this->types->rewritePackage($package); + + if ($rewritten !== null) { + return self::toPhpNamespace($rewritten); + } + } + + if ($this->options->phpNamespace !== null) { + return $this->options->phpNamespace; + } + + $phpNamespace = $descriptor->options?->phpNamespace; + if ($phpNamespace !== null && $phpNamespace !== '') { + return $phpNamespace; + } + + if ($package !== null && $package !== '') { + return self::toPhpNamespace($package); + } + + return null; + } + + /** + * @param string $type fully qualified protobuf type name, with or without the leading dot + * @return ?non-empty-string fully qualified php class name, or null when the type is not relocated + */ + public function type(string $type): ?string + { + $rewritten = $this->types->rewriteType($type); + + return $rewritten === null ? null : '\\' . self::toPhpNamespace($rewritten); + } + + /** + * @param non-empty-string $name + * @return non-empty-string + */ + private static function toPhpNamespace(string $name): string + { + /** @var non-empty-string */ + return Naming::joinNamespace(explode('.', $name)); + } +} diff --git a/tests/CompilerTest.php b/tests/CompilerTest.php index 74a7747..c62f112 100644 --- a/tests/CompilerTest.php +++ b/tests/CompilerTest.php @@ -30,7 +30,7 @@ public function testCompile(string $file): void $request = $decoder->decode($bytes, CodeGeneratorRequest::class); - $actual = self::collectFiles(new Compiler($encoder)->compile($request)); + $actual = self::collectFiles(new Compiler($encoder, __DIR__ . '/../map.json')->compile($request)); $expected = self::collectSnapshots(__DIR__ . '/snapshots/' . \dirname($file)); self::assertSame( diff --git a/tests/Plugin/Mapping/TypeMapTest.php b/tests/Plugin/Mapping/TypeMapTest.php new file mode 100644 index 0000000..cd1d4ef --- /dev/null +++ b/tests/Plugin/Mapping/TypeMapTest.php @@ -0,0 +1,129 @@ +rewriteType('google.protobuf.Any')); + self::assertNull($map->rewritePackage('google.protobuf')); + } + + public function testShippedMapIsValid(): void + { + $map = TypeMap::fromFile(__DIR__ . '/../../../map.json'); + + self::assertSame('thesis.google.protobuf.Any', $map->rewriteType('google.protobuf.Any')); + self::assertSame('thesis.google.protobuf', $map->rewritePackage('google.protobuf')); + } + + #[DataProvider('provideRewriteTypeCases')] + public function testRewriteType(string $type, ?string $expected): void + { + self::assertSame($expected, self::map()->rewriteType($type)); + } + + /** + * @return iterable + */ + public static function provideRewriteTypeCases(): iterable + { + yield 'pattern' => ['google.protobuf.Any', 'thesis.google.protobuf.Any']; + yield 'pattern with a leading dot' => ['.google.protobuf.Any', 'thesis.google.protobuf.Any']; + yield 'nested type' => ['google.protobuf.Struct.FieldsEntry', 'thesis.google.protobuf.Struct.FieldsEntry']; + yield 'nested package' => ['google.protobuf.compiler.Version', 'thesis.google.protobuf.compiler.Version']; + yield 'exact rule wins over a pattern' => ['google.rpc.Status', 'thesis.errors.Status']; + yield 'the longest pattern wins' => ['google.rpc.context.Rule', 'thesis.context.Rule']; + yield 'exact rule' => ['demo.Type', 'thesis.demo.Type']; + yield 'sibling of an exact rule' => ['demo.Another', null]; + yield 'unrelated package' => ['thesis.queue.v1.PushRequest', null]; + yield 'partially matching package' => ['googleapis.protobuf.Any', null]; + } + + #[DataProvider('provideRewritePackageCases')] + public function testRewritePackage(string $package, ?string $expected): void + { + self::assertSame($expected, self::map()->rewritePackage($package)); + } + + /** + * @return iterable + */ + public static function provideRewritePackageCases(): iterable + { + yield 'exactly the pattern' => ['google.protobuf', 'thesis.google.protobuf']; + yield 'nested package' => ['google.protobuf.compiler', 'thesis.google.protobuf.compiler']; + yield 'the longest pattern wins' => ['google.rpc.context', 'thesis.context']; + yield 'an exact rule says nothing about the package' => ['demo', null]; + yield 'unrelated package' => ['thesis.queue.v1', null]; + } + + #[DataProvider('provideInvalidMappingCases')] + public function testInvalidMapping(string $json, string $message): void + { + $this->expectException(InvalidNamespaceMapping::class); + $this->expectExceptionMessageMatches($message); + + TypeMap::fromJson($json, 'test.json'); + } + + /** + * @return iterable + */ + public static function provideInvalidMappingCases(): iterable + { + yield 'not a json' => ['{', '/is not a valid json/']; + yield 'not an object' => ['[]', '/must be a json object/']; + yield 'rules are not a list' => ['{"rules": {"a": "b"}}', '/must contain a list of rules/']; + yield 'rule is not an object' => ['{"rules": ["google.protobuf.*"]}', '/must be a json object/']; + yield 'unknown key' => ['{"rules": [{"from": "a.B", "to": "b.B", "when": "always"}]}', '/unknown keys: when/']; + yield 'missing to' => ['{"rules": [{"from": "a.B"}]}', "/'to' key/"]; + yield 'empty from' => ['{"rules": [{"from": "", "to": "b.B"}]}', "/'from' key/"]; + yield 'pattern mixed with an exact name' => ['{"rules": [{"from": "a.*", "to": "b.B"}]}', '/mixes a pattern with an exact name/']; + yield 'renaming a type' => ['{"rules": [{"from": "a.B", "to": "b.C"}]}', '/renames a type/']; + yield 'invalid name' => ['{"rules": [{"from": "a..B", "to": "b..B"}]}', '/is not a valid protobuf name/']; + yield 'invalid pattern' => ['{"rules": [{"from": "*", "to": "b.*"}]}', '/mixes a pattern with an exact name/']; + yield 'duplicated rule' => [ + '{"rules": [{"from": "a.*", "to": "b.*"}, {"from": "a.*", "to": "c.*"}]}', + "/declares 'a.\\*' more than once/", + ]; + } + + public function testDescriptionIsAllowed(): void + { + $map = TypeMap::fromJson('{"rules": [{"from": "a.*", "to": "b.*"}]}', 'test.json'); + + self::assertSame('b.C', $map->rewriteType('a.C')); + } + + private static function map(): TypeMap + { + return TypeMap::fromJson( + <<<'JSON' + { + "rules": [ + {"from": "google.protobuf.*", "to": "thesis.google.protobuf.*"}, + {"from": "google.rpc.*", "to": "thesis.rpc.*"}, + {"from": "google.rpc.context.*", "to": "thesis.context.*"}, + {"from": "google.rpc.Status", "to": "thesis.errors.Status"}, + {"from": "demo.Type", "to": "thesis.demo.Type"} + ] + } + JSON, + 'test.json', + ); + } +} diff --git a/tests/fixtures/namespace_mapping/status.proto b/tests/fixtures/namespace_mapping/status.proto new file mode 100644 index 0000000..a6cbecb --- /dev/null +++ b/tests/fixtures/namespace_mapping/status.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; + +package google.rpc; + +import "google/protobuf/timestamp.proto"; + +// The package is relocated into Thesis\Google\Rpc by map.json, while the protobuf +// name of every type stays google.rpc.*. +message Status { + enum Kind { + KIND_UNSPECIFIED = 0; + KIND_RETRYABLE = 1; + } + + message Detail { + string reason = 1; + } + + int32 code = 1; + Kind kind = 2; + Detail detail = 3; + google.protobuf.Timestamp created_at = 4; + + oneof payload { + string text = 5; + Detail structured = 6; + } +} + +service StatusService { + rpc Get(Status) returns (Status); +} diff --git a/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceClient.php b/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceClient.php index b8bbdde..2b23155 100644 --- a/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceClient.php +++ b/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceClient.php @@ -28,16 +28,16 @@ public function __construct( ) {} /** - * @return Client\ClientStreamChannel<\Thesis\Queue\PushRequest\Message, \Google\Protobuf\Empty_> + * @return Client\ClientStreamChannel<\Thesis\Queue\PushRequest\Message, \Thesis\Google\Protobuf\Empty_> */ public function push( Metadata $md = new Metadata(), Cancellation $cancellation = new NullCancellation(), ): Client\ClientStreamChannel { - /** @var Client\Invoke<\Thesis\Queue\PushRequest\Message, \Google\Protobuf\Empty_> $invoke */ + /** @var Client\Invoke<\Thesis\Queue\PushRequest\Message, \Thesis\Google\Protobuf\Empty_> $invoke */ $invoke = new Client\Invoke( method: '/Thesis.Queue.V1.QueueService/Push', - output: \Google\Protobuf\Empty_::class, + output: \Thesis\Google\Protobuf\Empty_::class, type: Grpc\RpcType::ClientStream, ); diff --git a/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceServer.php b/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceServer.php index 7c63067..c942a4a 100644 --- a/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceServer.php +++ b/tests/snapshots/grpc/Thesis/Queue/V1/QueueServiceServer.php @@ -22,13 +22,13 @@ interface QueueServiceServer { /** - * @param Server\ClientStreamChannel<\Thesis\Queue\PushRequest\Message, \Google\Protobuf\Empty_> $stream + * @param Server\ClientStreamChannel<\Thesis\Queue\PushRequest\Message, \Thesis\Google\Protobuf\Empty_> $stream */ public function push( Server\ClientStreamChannel $stream, Metadata $md, Cancellation $cancellation, - ): \Google\Protobuf\Empty_; + ): \Thesis\Google\Protobuf\Empty_; /** * @return iterable diff --git a/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/DescriptorRegistry.php b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/DescriptorRegistry.php new file mode 100644 index 0000000..e5f82ea --- /dev/null +++ b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/DescriptorRegistry.php @@ -0,0 +1,51 @@ +add(Registry\Descriptor::base64(self::STATUS_DESCRIPTOR_BUFFER), new File( + name: 'status.proto', + dependencies: [ + 'google/protobuf/timestamp.proto', + ], + messages: [ + new File\MessageDescriptor('google.rpc.Status', \Thesis\Google\Rpc\Status::class), + new File\MessageDescriptor('google.rpc.Status.Detail', \Thesis\Google\Rpc\Status\Detail::class), + ], + enums: [ + new File\EnumDescriptor('google.rpc.Status.Kind', \Thesis\Google\Rpc\Status\Kind::class), + ], + services: [ + new File\ServiceDescriptor( + name: 'google.rpc.StatusService', + methods: [ + new File\MethodDescriptor('Get', false, false), + ], + ), + ], + )); + } +} diff --git a/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status.php b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status.php new file mode 100644 index 0000000..5f3d9bd --- /dev/null +++ b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/Status.php @@ -0,0 +1,37 @@ + $invoke */ + $invoke = new Client\Invoke( + method: '/google.rpc.StatusService/Get', + output: \Thesis\Google\Rpc\Status::class, + type: Grpc\RpcType::Unary, + ); + + return $this->client->invoke( + request: $request, + invoke: $invoke, + md: $md, + cancellation: $cancellation, + ); + } +} diff --git a/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/StatusServiceServer.php b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/StatusServiceServer.php new file mode 100644 index 0000000..9f0e4ad --- /dev/null +++ b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/StatusServiceServer.php @@ -0,0 +1,28 @@ +server->get(...)), + type: Grpc\RpcType::Unary, + ), + ]); + } +} diff --git a/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/autoload.metadata.php b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/autoload.metadata.php new file mode 100644 index 0000000..42cd186 --- /dev/null +++ b/tests/snapshots/namespace_mapping/Thesis/Google/Rpc/autoload.metadata.php @@ -0,0 +1,14 @@ +register( + new \Thesis\Protobuf\Registry\OnceRegistrar(new \Thesis\Google\Rpc\DescriptorRegistry()), +); diff --git a/tests/snapshots/proto2/Proto/Api/V1/TestRequest.php b/tests/snapshots/proto2/Proto/Api/V1/TestRequest.php index a4c44f3..a29c3db 100644 --- a/tests/snapshots/proto2/Proto/Api/V1/TestRequest.php +++ b/tests/snapshots/proto2/Proto/Api/V1/TestRequest.php @@ -210,16 +210,16 @@ public function __construct( public \Proto\Api\V1\Foo $fooDefaulted = \Proto\Api\V1\Foo::FOO_BAR, #[Reflection\Field(406, new Reflection\MapT(Reflection\StringT::T, Reflection\StringT::T))] public Protobuf\Map $mapStringString = new Protobuf\Map(), - #[Reflection\Field(407, new Reflection\ObjectT(\Google\Protobuf\Timestamp::class))] - public ?\Google\Protobuf\Timestamp $knownTimestamp = null, - #[Reflection\Field(408, new Reflection\ObjectT(\Google\Protobuf\Duration::class))] - public ?\Google\Protobuf\Duration $knownDuration = null, - #[Reflection\Field(409, new Reflection\ObjectT(\Google\Protobuf\Struct::class))] - public ?\Google\Protobuf\Struct $knownStruct = null, - #[Reflection\Field(410, new Reflection\ObjectT(\Google\Protobuf\Empty_::class))] - public ?\Google\Protobuf\Empty_ $knownEmpty = null, - #[Reflection\Field(411, new Reflection\ObjectT(\Google\Protobuf\Any::class))] - public ?\Google\Protobuf\Any $knownAny = null, + #[Reflection\Field(407, new Reflection\ObjectT(\Thesis\Google\Protobuf\Timestamp::class))] + public ?\Thesis\Google\Protobuf\Timestamp $knownTimestamp = null, + #[Reflection\Field(408, new Reflection\ObjectT(\Thesis\Google\Protobuf\Duration::class))] + public ?\Thesis\Google\Protobuf\Duration $knownDuration = null, + #[Reflection\Field(409, new Reflection\ObjectT(\Thesis\Google\Protobuf\Struct::class))] + public ?\Thesis\Google\Protobuf\Struct $knownStruct = null, + #[Reflection\Field(410, new Reflection\ObjectT(\Thesis\Google\Protobuf\Empty_::class))] + public ?\Thesis\Google\Protobuf\Empty_ $knownEmpty = null, + #[Reflection\Field(411, new Reflection\ObjectT(\Thesis\Google\Protobuf\Any::class))] + public ?\Thesis\Google\Protobuf\Any $knownAny = null, #[Reflection\Field(412, new Reflection\ObjectT(\Proto\Api\V1\TestRequest\Nested::class))] public ?\Proto\Api\V1\TestRequest\Nested $nested = null, #[Reflection\Field(413, new Reflection\ObjectT(\Proto\Api\V1\TestRequest\Nested\Deep::class))]