diff --git a/src/Generator/Generator.php b/src/Generator/Generator.php index c80c2c3f..8a0b3625 100644 --- a/src/Generator/Generator.php +++ b/src/Generator/Generator.php @@ -19,6 +19,7 @@ use Laminas\Code\Generator\MethodGenerator; use Laminas\Code\Generator\ParameterGenerator; use Laminas\Code\Generator\PropertyGenerator; +use Laminas\Code\Generator\PropertyValueGenerator; use Laminas\Code\Generator\TypeGenerator; class Generator @@ -85,11 +86,83 @@ public function generateProperties(PropertyCollection $properties): array return $propertyGenerators; } + /** + * Generates the property holding all input properties that are not explicitly + * declared in the schema (for schemas combining 'properties' and 'additionalProperties'). + */ + public function generateAdditionalPropertiesProperty(PropertyInterface $additionalPropertiesItem): PropertyGenerator + { + $prop = new PropertyGenerator( + "additionalProperties", + new PropertyValueGenerator([], PropertyValueGenerator::TYPE_ARRAY_SHORT, PropertyValueGenerator::OUTPUT_SINGLE_LINE), + PropertyGenerator::FLAG_PRIVATE + ); + + $docBlock = new DocBlockGenerator( + "Properties from the input that are not explicitly declared in the schema", + null, + [new GenericTag("var", "arraytypeAnnotation()) . ">")] + ); + $docBlock->setWordWrap(false); + $prop->setDocBlock($docBlock); + + if ($this->generatorRequest->isAtLeastPHP("7.4")) { + $prop->setType(TypeGenerator::fromTypeString("array")); + } + + return $prop; + } + + public function generateAdditionalPropertiesGetter(PropertyInterface $additionalPropertiesItem): MethodGenerator + { + $docBlock = new DocBlockGenerator(null, null, [ + new ReturnTag("arraytypeAnnotation()) . ">"), + ]); + $docBlock->setWordWrap(false); + + $method = new MethodGenerator( + 'getAdditionalProperties', + [], + MethodGenerator::FLAG_PUBLIC, + 'return $this->additionalProperties;', + $docBlock + ); + + if ($this->generatorRequest->isAtLeastPHP("7.0")) { + $method->setReturnType("array"); + } + + return $method; + } + + public function generateAdditionalPropertiesSetter(PropertyInterface $additionalPropertiesItem): MethodGenerator + { + $docBlock = new DocBlockGenerator(null, null, [ + new ParamTag("additionalProperties", ["arraytypeAnnotation()) . ">"]), + new ReturnTag("self"), + ]); + $docBlock->setWordWrap(false); + + $method = new MethodGenerator( + 'withAdditionalProperties', + [new ParameterGenerator("additionalProperties", "array")], + MethodGenerator::FLAG_PUBLIC, + "\$clone = clone \$this;\n\$clone->additionalProperties = \$additionalProperties;\n\nreturn \$clone;", + $docBlock + ); + + if ($this->generatorRequest->isAtLeastPHP("7.0")) { + $method->setReturnType("self"); + } + + return $method; + } + /** * @param PropertyCollection $properties * @return MethodGenerator */ - public function generateBuildMethod(PropertyCollection $properties): MethodGenerator + public function generateBuildMethod(PropertyCollection $properties, ?PropertyInterface $additionalPropertiesItem = null): MethodGenerator { $requiredProperties = $properties->filter(PropertyCollectionFilterFactory::required()); $optionalProperties = $properties->filter(PropertyCollectionFilterFactory::optional()); @@ -148,6 +221,7 @@ public function generateBuildMethod(PropertyCollection $properties): MethodGener $properties->generateJSONToTypeConversionCode($inputVarName, object: true) . "\n\n" . '$obj = new self(' . join(", ", $constructorParams) . ');' . "\n" . join("\n", $assignments) . "\n" . + $this->generateAdditionalPropertiesCollectionCode($properties, $additionalPropertiesItem, $inputVarName) . 'return $obj;', $docBlock ); @@ -159,11 +233,32 @@ public function generateBuildMethod(PropertyCollection $properties): MethodGener return $method; } + private function generateAdditionalPropertiesCollectionCode(PropertyCollection $properties, ?PropertyInterface $additionalPropertiesItem, string $inputVarName): string + { + if ($additionalPropertiesItem === null) { + return ""; + } + + $declaredKeys = []; + foreach ($properties as $property) { + $declaredKeys[] = var_export($property->key(), true); + } + + $mapping = $additionalPropertiesItem->generateInputMappingExpr('$value'); + + return "foreach (get_object_vars(\$$inputVarName) as \$key => \$value) {\n" . + " if (in_array(\$key, [" . join(", ", $declaredKeys) . "], true)) {\n" . + " continue;\n" . + " }\n" . + " \$obj->additionalProperties[\$key] = {$mapping};\n" . + "}\n"; + } + /** * @param PropertyCollection $properties * @return MethodGenerator */ - public function generateToJSONMethod(PropertyCollection $properties): MethodGenerator + public function generateToJSONMethod(PropertyCollection $properties, ?PropertyInterface $additionalPropertiesItem = null): MethodGenerator { $docBlock = new DocBlockGenerator( "Converts this object back to a simple array that can be JSON-serialized", @@ -172,11 +267,24 @@ public function generateToJSONMethod(PropertyCollection $properties): MethodGene ); $docBlock->setWordWrap(false); + $additionalPropertiesCode = ""; + if ($additionalPropertiesItem !== null) { + $mapping = $additionalPropertiesItem->generateOutputMappingExpr('$value'); + + // Additional properties are written first so that declared properties + // always win in case of a key collision. + $additionalPropertiesCode = + "foreach (\$this->additionalProperties as \$key => \$value) {\n" . + " \$output[\$key] = {$mapping};\n" . + "}\n"; + } + $method = new MethodGenerator( 'toJson', [], MethodGenerator::FLAG_PUBLIC, '$output = [];' . "\n" . + $additionalPropertiesCode . $properties->generateTypeToJSONConversionCode('output') . "\n\n" . 'return $output;', $docBlock @@ -241,7 +349,7 @@ public function generateValidateMethod(): MethodGenerator * @param PropertyCollection $properties * @return MethodGenerator */ - public function generateCloneMethod(PropertyCollection $properties): MethodGenerator + public function generateCloneMethod(PropertyCollection $properties, ?PropertyInterface $additionalPropertiesItem = null): MethodGenerator { $clones = []; @@ -252,6 +360,15 @@ public function generateCloneMethod(PropertyCollection $properties): MethodGener } } + if ($additionalPropertiesItem !== null) { + $cloneExpr = $additionalPropertiesItem->generateCloneExpr('$value'); + if ($cloneExpr !== '$value') { + $clones[] = $this->generatorRequest->isAtLeastPHP("7.4") + ? "\$this->additionalProperties = array_map(fn (\$value) => {$cloneExpr}, \$this->additionalProperties);" + : "\$this->additionalProperties = array_map(function (\$value) { return {$cloneExpr}; }, \$this->additionalProperties);"; + } + } + return new MethodGenerator( '__clone', [], diff --git a/src/Generator/NamespaceInferrer.php b/src/Generator/NamespaceInferrer.php index c9add02d..9c83ea32 100644 --- a/src/Generator/NamespaceInferrer.php +++ b/src/Generator/NamespaceInferrer.php @@ -25,7 +25,7 @@ public function inferNamespaceFromTargetDirectory(string $directory): string } if ($directory[0] !== "/") { - $directory = $workingDirectory . PATH_SEPARATOR . $directory; + $directory = $workingDirectory . DIRECTORY_SEPARATOR . $directory; } list($root, $composer) = $this->getComposerJSONForDirectory($directory); diff --git a/src/Generator/Property/NestedObjectProperty.php b/src/Generator/Property/NestedObjectProperty.php index 8849ef1e..7f06fe21 100644 --- a/src/Generator/Property/NestedObjectProperty.php +++ b/src/Generator/Property/NestedObjectProperty.php @@ -13,8 +13,12 @@ public static function canHandleSchema(array $schema): bool { $isObject = isset($schema["type"]) && $schema["type"] === "object" || isset($schema["properties"]); $isAssociativeArray = isset($schema["additionalProperties"]) && is_array($schema["additionalProperties"]); + $hasProperties = isset($schema["properties"]) && count($schema["properties"]) > 0; - return $isObject && !$isAssociativeArray; + // Schemas with declared properties are generated as classes, even when they + // also allow additional properties; purely map-like schemas (only + // 'additionalProperties') are handled by the array property types instead. + return $isObject && ($hasProperties || !$isAssociativeArray); } public function isComplex(): bool diff --git a/src/Generator/Property/OptionalPropertyDecorator.php b/src/Generator/Property/OptionalPropertyDecorator.php index acbf9210..166e9172 100644 --- a/src/Generator/Property/OptionalPropertyDecorator.php +++ b/src/Generator/Property/OptionalPropertyDecorator.php @@ -71,6 +71,12 @@ public function convertTypeToJSON(string $outputVarName = 'output'): string $name = $this->inner->name(); $inner = $this->inner->convertTypeToJSON($outputVarName); + // Properties with a default value are declared non-nullable and initialized + // with their default, so isset() on them would always be true. + if (isset($this->schema()["default"])) { + return $inner; + } + return "if (isset(\$this->{$name})) {\n" . $this->indentCode($inner, 1) . "\n}"; } @@ -136,6 +142,11 @@ public function cloneProperty(): ?string $inner = $this->inner->cloneProperty(); if ($inner !== null) { + // See convertTypeToJSON(): properties with a default value are always set. + if (isset($this->schema()["default"])) { + return $inner; + } + return "if (isset(\$this->{$name})) {\n" . $this->indentCode($inner, 1) . "\n}"; } diff --git a/src/Generator/PropertyBuilder.php b/src/Generator/PropertyBuilder.php index b74c06d6..a81bad92 100644 --- a/src/Generator/PropertyBuilder.php +++ b/src/Generator/PropertyBuilder.php @@ -51,8 +51,6 @@ class PropertyBuilder */ public static function buildPropertyFromSchema(GeneratorRequest $req, string $name, array $definition, bool $isRequired): PropertyInterface { - self::testInvariants($definition); - foreach (self::$propertyTypes as $propertyType) { if ($propertyType::canHandleSchema($definition)) { /** @var PropertyInterface $property */ @@ -71,14 +69,4 @@ public static function buildPropertyFromSchema(GeneratorRequest $req, string $na /** @psalm-suppress PossiblyFalseOperand */ throw new GeneratorException("cannot map type " . json_encode($definition)); } - - private static function testInvariants(array $definition): void - { - $hasAdditionalProperties = isset($definition["additionalProperties"]) && is_array($definition["additionalProperties"]) && count($definition["additionalProperties"]) > 0; - $hasProperties = isset($definition["properties"]) && is_array($definition["properties"]) && count($definition["properties"]) > 0; - - if ($hasProperties && $hasAdditionalProperties) { - throw new GeneratorException("using 'properties' and 'additionalProperties' in the same schema is currently not supported."); - } - } } \ No newline at end of file diff --git a/src/Generator/SchemaToClass.php b/src/Generator/SchemaToClass.php index 91851cff..7e0cd15d 100644 --- a/src/Generator/SchemaToClass.php +++ b/src/Generator/SchemaToClass.php @@ -77,10 +77,14 @@ public function schemaToClass(GeneratorRequest $req): void } } + $additionalPropertiesItem = $this->buildAdditionalPropertiesItem($req, $schema); + foreach ($propertiesFromSchema as $property) { $property->generateSubTypes($this); } + $additionalPropertiesItem?->generateSubTypes($this); + $codeGenerator = new Generator($req); $properties = [ @@ -88,14 +92,22 @@ public function schemaToClass(GeneratorRequest $req): void ...$codeGenerator->generateProperties($propertiesFromSchema), ]; + if ($additionalPropertiesItem !== null) { + $properties[] = $codeGenerator->generateAdditionalPropertiesProperty($additionalPropertiesItem); + } + $methods = [ $codeGenerator->generateConstructor($propertiesFromSchema), ...$codeGenerator->generateGetterMethods($propertiesFromSchema), ...$codeGenerator->generateSetterMethods($propertiesFromSchema), - $codeGenerator->generateBuildMethod($propertiesFromSchema), - $codeGenerator->generateToJSONMethod($propertiesFromSchema), + ...($additionalPropertiesItem !== null ? [ + $codeGenerator->generateAdditionalPropertiesGetter($additionalPropertiesItem), + $codeGenerator->generateAdditionalPropertiesSetter($additionalPropertiesItem), + ] : []), + $codeGenerator->generateBuildMethod($propertiesFromSchema, $additionalPropertiesItem), + $codeGenerator->generateToJSONMethod($propertiesFromSchema, $additionalPropertiesItem), $codeGenerator->generateValidateMethod(), - $codeGenerator->generateCloneMethod($propertiesFromSchema), + $codeGenerator->generateCloneMethod($propertiesFromSchema, $additionalPropertiesItem), ]; $cls = new ClassGenerator( @@ -131,4 +143,28 @@ public function schemaToClass(GeneratorRequest $req): void $this->writer->writeFile($filename, $content); } + /** + * Builds a pseudo-property representing the value type of 'additionalProperties' + * for schemas that combine 'properties' and 'additionalProperties'. Returns null + * when the schema does not use this combination; purely map-like schemas (only + * 'additionalProperties') are represented as plain array properties instead. + * + * @throws GeneratorException + */ + private function buildAdditionalPropertiesItem(GeneratorRequest $req, array $schema): ?Property\PropertyInterface + { + $hasProperties = isset($schema["properties"]) && count($schema["properties"]) > 0; + $hasAdditionalProperties = isset($schema["additionalProperties"]) && is_array($schema["additionalProperties"]) && count($schema["additionalProperties"]) > 0; + + if (!$hasProperties || !$hasAdditionalProperties) { + return null; + } + + if (isset($schema["properties"]["additionalProperties"])) { + throw new GeneratorException("schemas using 'additionalProperties' together with a regular property named 'additionalProperties' are not supported"); + } + + return PropertyBuilder::buildPropertyFromSchema($req, "additionalPropertiesItem", $schema["additionalProperties"], true); + } + } diff --git a/src/Spec/Specification.php b/src/Spec/Specification.php index 5abd6980..f7e7e8e6 100644 --- a/src/Spec/Specification.php +++ b/src/Spec/Specification.php @@ -123,7 +123,7 @@ public function __construct(array $files) /** * @return int|string|null */ - public function getTargetPHPVersion() : int|string|null + public function getTargetPHPVersion(): int|string|null { return $this->targetPHPVersion; } @@ -131,7 +131,7 @@ public function getTargetPHPVersion() : int|string|null /** * @return SpecificationFilesItem[] */ - public function getFiles() : array + public function getFiles(): array { return $this->files; } @@ -139,7 +139,7 @@ public function getFiles() : array /** * @return SpecificationOptions|null */ - public function getOptions() : ?SpecificationOptions + public function getOptions(): ?SpecificationOptions { return $this->options ?? null; } @@ -148,7 +148,7 @@ public function getOptions() : ?SpecificationOptions * @param int|string $targetPHPVersion * @return self */ - public function withTargetPHPVersion(int|string $targetPHPVersion) : self + public function withTargetPHPVersion(int|string $targetPHPVersion): self { $clone = clone $this; $clone->targetPHPVersion = $targetPHPVersion; @@ -159,7 +159,7 @@ public function withTargetPHPVersion(int|string $targetPHPVersion) : self /** * @return self */ - public function withoutTargetPHPVersion() : self + public function withoutTargetPHPVersion(): self { $clone = clone $this; unset($clone->targetPHPVersion); @@ -171,7 +171,7 @@ public function withoutTargetPHPVersion() : self * @param SpecificationFilesItem[] $files * @return self */ - public function withFiles(array $files) : self + public function withFiles(array $files): self { $clone = clone $this; $clone->files = $files; @@ -183,7 +183,7 @@ public function withFiles(array $files) : self * @param SpecificationOptions $options * @return self */ - public function withOptions(SpecificationOptions $options) : self + public function withOptions(SpecificationOptions $options): self { $clone = clone $this; $clone->options = $options; @@ -194,7 +194,7 @@ public function withOptions(SpecificationOptions $options) : self /** * @return self */ - public function withoutOptions() : self + public function withoutOptions(): self { $clone = clone $this; unset($clone->options); @@ -210,7 +210,7 @@ public function withoutOptions() : self * @return Specification Created instance * @throws \InvalidArgumentException */ - public static function buildFromInput(array|object $input, bool $validate = true) : Specification + public static function buildFromInput(array|object $input, bool $validate = true): Specification { $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; if ($validate) { @@ -241,7 +241,7 @@ public static function buildFromInput(array|object $input, bool $validate = true * * @return array Converted array */ - public function toJson() : array + public function toJson(): array { $output = []; if (isset($this->targetPHPVersion)) { @@ -265,7 +265,7 @@ public function toJson() : array * @return bool Validation result * @throws \InvalidArgumentException */ - public static function validateInput(array|object $input, bool $return = false) : bool + public static function validateInput(array|object $input, bool $return = false): bool { $validator = new \JsonSchema\Validator(); $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; diff --git a/src/Spec/SpecificationFilesItem.php b/src/Spec/SpecificationFilesItem.php index 5bc34f2f..4b5069a2 100644 --- a/src/Spec/SpecificationFilesItem.php +++ b/src/Spec/SpecificationFilesItem.php @@ -68,7 +68,7 @@ public function __construct(string $input, string $className, string $targetDire /** * @return string */ - public function getInput() : string + public function getInput(): string { return $this->input; } @@ -76,7 +76,7 @@ public function getInput() : string /** * @return string */ - public function getClassName() : string + public function getClassName(): string { return $this->className; } @@ -84,7 +84,7 @@ public function getClassName() : string /** * @return string */ - public function getTargetDirectory() : string + public function getTargetDirectory(): string { return $this->targetDirectory; } @@ -92,7 +92,7 @@ public function getTargetDirectory() : string /** * @return string|null */ - public function getTargetNamespace() : ?string + public function getTargetNamespace(): ?string { return $this->targetNamespace ?? null; } @@ -101,7 +101,7 @@ public function getTargetNamespace() : ?string * @param string $input * @return self */ - public function withInput(string $input) : self + public function withInput(string $input): self { $validator = new \JsonSchema\Validator(); $validator->validate($input, self::$internalValidationSchema['properties']['input']); @@ -119,7 +119,7 @@ public function withInput(string $input) : self * @param string $className * @return self */ - public function withClassName(string $className) : self + public function withClassName(string $className): self { $validator = new \JsonSchema\Validator(); $validator->validate($className, self::$internalValidationSchema['properties']['className']); @@ -137,7 +137,7 @@ public function withClassName(string $className) : self * @param string $targetDirectory * @return self */ - public function withTargetDirectory(string $targetDirectory) : self + public function withTargetDirectory(string $targetDirectory): self { $validator = new \JsonSchema\Validator(); $validator->validate($targetDirectory, self::$internalValidationSchema['properties']['targetDirectory']); @@ -155,7 +155,7 @@ public function withTargetDirectory(string $targetDirectory) : self * @param string $targetNamespace * @return self */ - public function withTargetNamespace(string $targetNamespace) : self + public function withTargetNamespace(string $targetNamespace): self { $validator = new \JsonSchema\Validator(); $validator->validate($targetNamespace, self::$internalValidationSchema['properties']['targetNamespace']); @@ -172,7 +172,7 @@ public function withTargetNamespace(string $targetNamespace) : self /** * @return self */ - public function withoutTargetNamespace() : self + public function withoutTargetNamespace(): self { $clone = clone $this; unset($clone->targetNamespace); @@ -188,7 +188,7 @@ public function withoutTargetNamespace() : self * @return SpecificationFilesItem Created instance * @throws \InvalidArgumentException */ - public static function buildFromInput(array|object $input2, bool $validate = true) : SpecificationFilesItem + public static function buildFromInput(array|object $input2, bool $validate = true): SpecificationFilesItem { $input2 = is_array($input2) ? \JsonSchema\Validator::arrayToObjectRecursive($input2) : $input2; if ($validate) { @@ -213,7 +213,7 @@ public static function buildFromInput(array|object $input2, bool $validate = tru * * @return array Converted array */ - public function toJson() : array + public function toJson(): array { $output = []; $output['input'] = $this->input; @@ -234,7 +234,7 @@ public function toJson() : array * @return bool Validation result * @throws \InvalidArgumentException */ - public static function validateInput(array|object $input, bool $return = false) : bool + public static function validateInput(array|object $input, bool $return = false): bool { $validator = new \JsonSchema\Validator(); $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; diff --git a/src/Spec/SpecificationOptions.php b/src/Spec/SpecificationOptions.php index 952396a4..41146ca8 100644 --- a/src/Spec/SpecificationOptions.php +++ b/src/Spec/SpecificationOptions.php @@ -90,7 +90,7 @@ public function __construct() /** * @return bool */ - public function getDisableStrictTypes() : bool + public function getDisableStrictTypes(): bool { return $this->disableStrictTypes; } @@ -98,7 +98,7 @@ public function getDisableStrictTypes() : bool /** * @return bool */ - public function getTreatValuesWithDefaultAsOptional() : bool + public function getTreatValuesWithDefaultAsOptional(): bool { return $this->treatValuesWithDefaultAsOptional; } @@ -106,7 +106,7 @@ public function getTreatValuesWithDefaultAsOptional() : bool /** * @return bool */ - public function getInlineAllofReferences() : bool + public function getInlineAllofReferences(): bool { return $this->inlineAllofReferences; } @@ -114,7 +114,7 @@ public function getInlineAllofReferences() : bool /** * @return int|string */ - public function getTargetPHPVersion() : int|string + public function getTargetPHPVersion(): int|string { return $this->targetPHPVersion; } @@ -122,7 +122,7 @@ public function getTargetPHPVersion() : int|string /** * @return string */ - public function getNewValidatorClassExpr() : string + public function getNewValidatorClassExpr(): string { return $this->newValidatorClassExpr; } @@ -131,7 +131,7 @@ public function getNewValidatorClassExpr() : string * @param bool $disableStrictTypes * @return self */ - public function withDisableStrictTypes(bool $disableStrictTypes) : self + public function withDisableStrictTypes(bool $disableStrictTypes): self { $validator = new \JsonSchema\Validator(); $validator->validate($disableStrictTypes, self::$internalValidationSchema['properties']['disableStrictTypes']); @@ -148,7 +148,7 @@ public function withDisableStrictTypes(bool $disableStrictTypes) : self /** * @return self */ - public function withoutDisableStrictTypes() : self + public function withoutDisableStrictTypes(): self { $clone = clone $this; $clone->disableStrictTypes = false; @@ -160,7 +160,7 @@ public function withoutDisableStrictTypes() : self * @param bool $treatValuesWithDefaultAsOptional * @return self */ - public function withTreatValuesWithDefaultAsOptional(bool $treatValuesWithDefaultAsOptional) : self + public function withTreatValuesWithDefaultAsOptional(bool $treatValuesWithDefaultAsOptional): self { $validator = new \JsonSchema\Validator(); $validator->validate($treatValuesWithDefaultAsOptional, self::$internalValidationSchema['properties']['treatValuesWithDefaultAsOptional']); @@ -177,7 +177,7 @@ public function withTreatValuesWithDefaultAsOptional(bool $treatValuesWithDefaul /** * @return self */ - public function withoutTreatValuesWithDefaultAsOptional() : self + public function withoutTreatValuesWithDefaultAsOptional(): self { $clone = clone $this; $clone->treatValuesWithDefaultAsOptional = false; @@ -189,7 +189,7 @@ public function withoutTreatValuesWithDefaultAsOptional() : self * @param bool $inlineAllofReferences * @return self */ - public function withInlineAllofReferences(bool $inlineAllofReferences) : self + public function withInlineAllofReferences(bool $inlineAllofReferences): self { $validator = new \JsonSchema\Validator(); $validator->validate($inlineAllofReferences, self::$internalValidationSchema['properties']['inlineAllofReferences']); @@ -206,7 +206,7 @@ public function withInlineAllofReferences(bool $inlineAllofReferences) : self /** * @return self */ - public function withoutInlineAllofReferences() : self + public function withoutInlineAllofReferences(): self { $clone = clone $this; $clone->inlineAllofReferences = false; @@ -218,7 +218,7 @@ public function withoutInlineAllofReferences() : self * @param int|string $targetPHPVersion * @return self */ - public function withTargetPHPVersion(int|string $targetPHPVersion) : self + public function withTargetPHPVersion(int|string $targetPHPVersion): self { $clone = clone $this; $clone->targetPHPVersion = $targetPHPVersion; @@ -229,7 +229,7 @@ public function withTargetPHPVersion(int|string $targetPHPVersion) : self /** * @return self */ - public function withoutTargetPHPVersion() : self + public function withoutTargetPHPVersion(): self { $clone = clone $this; $clone->targetPHPVersion = '8.2.0'; @@ -241,7 +241,7 @@ public function withoutTargetPHPVersion() : self * @param string $newValidatorClassExpr * @return self */ - public function withNewValidatorClassExpr(string $newValidatorClassExpr) : self + public function withNewValidatorClassExpr(string $newValidatorClassExpr): self { $validator = new \JsonSchema\Validator(); $validator->validate($newValidatorClassExpr, self::$internalValidationSchema['properties']['newValidatorClassExpr']); @@ -258,7 +258,7 @@ public function withNewValidatorClassExpr(string $newValidatorClassExpr) : self /** * @return self */ - public function withoutNewValidatorClassExpr() : self + public function withoutNewValidatorClassExpr(): self { $clone = clone $this; $clone->newValidatorClassExpr = 'new \\JsonSchema\\Validator()'; @@ -274,7 +274,7 @@ public function withoutNewValidatorClassExpr() : self * @return SpecificationOptions Created instance * @throws \InvalidArgumentException */ - public static function buildFromInput(array|object $input, bool $validate = true) : SpecificationOptions + public static function buildFromInput(array|object $input, bool $validate = true): SpecificationOptions { $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; if ($validate) { @@ -319,26 +319,16 @@ public static function buildFromInput(array|object $input, bool $validate = true * * @return array Converted array */ - public function toJson() : array + public function toJson(): array { $output = []; - if (isset($this->disableStrictTypes)) { - $output['disableStrictTypes'] = $this->disableStrictTypes; - } - if (isset($this->treatValuesWithDefaultAsOptional)) { - $output['treatValuesWithDefaultAsOptional'] = $this->treatValuesWithDefaultAsOptional; - } - if (isset($this->inlineAllofReferences)) { - $output['inlineAllofReferences'] = $this->inlineAllofReferences; - } - if (isset($this->targetPHPVersion)) { - $output['targetPHPVersion'] = match (true) { - is_int($this->targetPHPVersion), is_string($this->targetPHPVersion) => $this->targetPHPVersion, - }; - } - if (isset($this->newValidatorClassExpr)) { - $output['newValidatorClassExpr'] = $this->newValidatorClassExpr; - } + $output['disableStrictTypes'] = $this->disableStrictTypes; + $output['treatValuesWithDefaultAsOptional'] = $this->treatValuesWithDefaultAsOptional; + $output['inlineAllofReferences'] = $this->inlineAllofReferences; + $output['targetPHPVersion'] = match (true) { + is_int($this->targetPHPVersion), is_string($this->targetPHPVersion) => $this->targetPHPVersion, + }; + $output['newValidatorClassExpr'] = $this->newValidatorClassExpr; return $output; } @@ -351,7 +341,7 @@ public function toJson() : array * @return bool Validation result * @throws \InvalidArgumentException */ - public static function validateInput(array|object $input, bool $return = false) : bool + public static function validateInput(array|object $input, bool $return = false): bool { $validator = new \JsonSchema\Validator(); $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; @@ -369,11 +359,9 @@ public static function validateInput(array|object $input, bool $return = false) public function __clone() { - if (isset($this->targetPHPVersion)) { - $this->targetPHPVersion = match (true) { - is_int($this->targetPHPVersion), is_string($this->targetPHPVersion) => $this->targetPHPVersion, - }; - } + $this->targetPHPVersion = match (true) { + is_int($this->targetPHPVersion), is_string($this->targetPHPVersion) => $this->targetPHPVersion, + }; } } diff --git a/tests/Generator/Fixtures/DefaultValue/Output/Foo.php b/tests/Generator/Fixtures/DefaultValue/Output/Foo.php index af69ac3d..6db0dc69 100644 --- a/tests/Generator/Fixtures/DefaultValue/Output/Foo.php +++ b/tests/Generator/Fixtures/DefaultValue/Output/Foo.php @@ -157,12 +157,8 @@ public static function buildFromInput(array|object $input, bool $validate = true public function toJson(): array { $output = []; - if (isset($this->limit)) { - $output['limit'] = $this->limit; - } - if (isset($this->skip)) { - $output['skip'] = $this->skip; - } + $output['limit'] = $this->limit; + $output['skip'] = $this->skip; return $output; } diff --git a/tests/Generator/Fixtures/PropsWithAdditionalObjectProps/Output/Foo.php b/tests/Generator/Fixtures/PropsWithAdditionalObjectProps/Output/Foo.php new file mode 100644 index 00000000..c691a034 --- /dev/null +++ b/tests/Generator/Fixtures/PropsWithAdditionalObjectProps/Output/Foo.php @@ -0,0 +1,175 @@ + 'object', + 'required' => [ + 'name', + ], + 'properties' => [ + 'name' => [ + 'type' => 'string', + ], + ], + 'additionalProperties' => [ + 'type' => 'object', + 'required' => [ + 'value', + ], + 'properties' => [ + 'value' => [ + 'type' => 'string', + ], + ], + ], + ]; + + /** + * @var string + */ + private string $name; + + /** + * Properties from the input that are not explicitly declared in the schema + * + * @var array + */ + private array $additionalProperties = []; + + /** + * @param string $name + */ + public function __construct(string $name) + { + $this->name = $name; + } + + /** + * @return string + */ + public function getName(): string + { + return $this->name; + } + + /** + * @param string $name + * @return self + */ + public function withName(string $name): self + { + $validator = new \JsonSchema\Validator(); + $validator->validate($name, self::$internalValidationSchema['properties']['name']); + if (!$validator->isValid()) { + throw new \InvalidArgumentException($validator->getErrors()[0]['message']); + } + + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + /** + * @return array + */ + public function getAdditionalProperties(): array + { + return $this->additionalProperties; + } + + /** + * @param array $additionalProperties + * @return self + */ + public function withAdditionalProperties(array $additionalProperties): self + { + $clone = clone $this; + $clone->additionalProperties = $additionalProperties; + + return $clone; + } + + /** + * Builds a new instance from an input array + * + * @param array|object $input Input data + * @param bool $validate Set this to false to skip validation; use at own risk + * @return Foo Created instance + * @throws \InvalidArgumentException + */ + public static function buildFromInput(array|object $input, bool $validate = true): Foo + { + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + if ($validate) { + static::validateInput($input); + } + + $name = $input->{'name'}; + + $obj = new self($name); + + foreach (get_object_vars($input) as $key => $value) { + if (in_array($key, ['name'], true)) { + continue; + } + $obj->additionalProperties[$key] = FooAdditionalPropertiesItem::buildFromInput($value, validate: $validate); + } + return $obj; + } + + /** + * Converts this object back to a simple array that can be JSON-serialized + * + * @return array Converted array + */ + public function toJson(): array + { + $output = []; + foreach ($this->additionalProperties as $key => $value) { + $output[$key] = ($value)->toJson(); + } + $output['name'] = $this->name; + + return $output; + } + + /** + * Validates an input array + * + * @param array|object $input Input data + * @param bool $return Return instead of throwing errors + * @return bool Validation result + * @throws \InvalidArgumentException + */ + public static function validateInput(array|object $input, bool $return = false): bool + { + $validator = new \JsonSchema\Validator(); + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + $validator->validate($input, self::$internalValidationSchema); + + if (!$validator->isValid() && !$return) { + $errors = array_map(function(array $e): string { + return $e["property"] . ": " . $e["message"]; + }, $validator->getErrors()); + throw new \InvalidArgumentException(join(", ", $errors)); + } + + return $validator->isValid(); + } + + public function __clone() + { + $this->additionalProperties = array_map(fn ($value) => clone $value, $this->additionalProperties); + } +} \ No newline at end of file diff --git a/tests/Generator/Fixtures/PropsWithAdditionalObjectProps/Output/FooAdditionalPropertiesItem.php b/tests/Generator/Fixtures/PropsWithAdditionalObjectProps/Output/FooAdditionalPropertiesItem.php new file mode 100644 index 00000000..45e4f67b --- /dev/null +++ b/tests/Generator/Fixtures/PropsWithAdditionalObjectProps/Output/FooAdditionalPropertiesItem.php @@ -0,0 +1,127 @@ + 'object', + 'required' => [ + 'value', + ], + 'properties' => [ + 'value' => [ + 'type' => 'string', + ], + ], + ]; + + /** + * @var string + */ + private string $value; + + /** + * @param string $value + */ + public function __construct(string $value) + { + $this->value = $value; + } + + /** + * @return string + */ + public function getValue(): string + { + return $this->value; + } + + /** + * @param string $value + * @return self + */ + public function withValue(string $value): self + { + $validator = new \JsonSchema\Validator(); + $validator->validate($value, self::$internalValidationSchema['properties']['value']); + if (!$validator->isValid()) { + throw new \InvalidArgumentException($validator->getErrors()[0]['message']); + } + + $clone = clone $this; + $clone->value = $value; + + return $clone; + } + + /** + * Builds a new instance from an input array + * + * @param array|object $input Input data + * @param bool $validate Set this to false to skip validation; use at own risk + * @return FooAdditionalPropertiesItem Created instance + * @throws \InvalidArgumentException + */ + public static function buildFromInput(array|object $input, bool $validate = true): FooAdditionalPropertiesItem + { + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + if ($validate) { + static::validateInput($input); + } + + $value = $input->{'value'}; + + $obj = new self($value); + + return $obj; + } + + /** + * Converts this object back to a simple array that can be JSON-serialized + * + * @return array Converted array + */ + public function toJson(): array + { + $output = []; + $output['value'] = $this->value; + + return $output; + } + + /** + * Validates an input array + * + * @param array|object $input Input data + * @param bool $return Return instead of throwing errors + * @return bool Validation result + * @throws \InvalidArgumentException + */ + public static function validateInput(array|object $input, bool $return = false): bool + { + $validator = new \JsonSchema\Validator(); + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + $validator->validate($input, self::$internalValidationSchema); + + if (!$validator->isValid() && !$return) { + $errors = array_map(function(array $e): string { + return $e["property"] . ": " . $e["message"]; + }, $validator->getErrors()); + throw new \InvalidArgumentException(join(", ", $errors)); + } + + return $validator->isValid(); + } + + public function __clone() + { + } +} \ No newline at end of file diff --git a/tests/Generator/Fixtures/PropsWithAdditionalObjectProps/schema.yaml b/tests/Generator/Fixtures/PropsWithAdditionalObjectProps/schema.yaml new file mode 100644 index 00000000..e067add1 --- /dev/null +++ b/tests/Generator/Fixtures/PropsWithAdditionalObjectProps/schema.yaml @@ -0,0 +1,13 @@ +type: object +required: + - name +properties: + name: + type: string +additionalProperties: + type: object + required: + - value + properties: + value: + type: string diff --git a/tests/Generator/Fixtures/PropsWithAdditionalProps/Output/Foo.php b/tests/Generator/Fixtures/PropsWithAdditionalProps/Output/Foo.php new file mode 100644 index 00000000..b36ae31d --- /dev/null +++ b/tests/Generator/Fixtures/PropsWithAdditionalProps/Output/Foo.php @@ -0,0 +1,166 @@ + 'object', + 'required' => [ + 'reason', + ], + 'properties' => [ + 'reason' => [ + 'type' => 'string', + ], + ], + 'additionalProperties' => [ + 'type' => 'string', + ], + ]; + + /** + * @var string + */ + private string $reason; + + /** + * Properties from the input that are not explicitly declared in the schema + * + * @var array + */ + private array $additionalProperties = []; + + /** + * @param string $reason + */ + public function __construct(string $reason) + { + $this->reason = $reason; + } + + /** + * @return string + */ + public function getReason(): string + { + return $this->reason; + } + + /** + * @param string $reason + * @return self + */ + public function withReason(string $reason): self + { + $validator = new \JsonSchema\Validator(); + $validator->validate($reason, self::$internalValidationSchema['properties']['reason']); + if (!$validator->isValid()) { + throw new \InvalidArgumentException($validator->getErrors()[0]['message']); + } + + $clone = clone $this; + $clone->reason = $reason; + + return $clone; + } + + /** + * @return array + */ + public function getAdditionalProperties(): array + { + return $this->additionalProperties; + } + + /** + * @param array $additionalProperties + * @return self + */ + public function withAdditionalProperties(array $additionalProperties): self + { + $clone = clone $this; + $clone->additionalProperties = $additionalProperties; + + return $clone; + } + + /** + * Builds a new instance from an input array + * + * @param array|object $input Input data + * @param bool $validate Set this to false to skip validation; use at own risk + * @return Foo Created instance + * @throws \InvalidArgumentException + */ + public static function buildFromInput(array|object $input, bool $validate = true): Foo + { + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + if ($validate) { + static::validateInput($input); + } + + $reason = $input->{'reason'}; + + $obj = new self($reason); + + foreach (get_object_vars($input) as $key => $value) { + if (in_array($key, ['reason'], true)) { + continue; + } + $obj->additionalProperties[$key] = $value; + } + return $obj; + } + + /** + * Converts this object back to a simple array that can be JSON-serialized + * + * @return array Converted array + */ + public function toJson(): array + { + $output = []; + foreach ($this->additionalProperties as $key => $value) { + $output[$key] = $value; + } + $output['reason'] = $this->reason; + + return $output; + } + + /** + * Validates an input array + * + * @param array|object $input Input data + * @param bool $return Return instead of throwing errors + * @return bool Validation result + * @throws \InvalidArgumentException + */ + public static function validateInput(array|object $input, bool $return = false): bool + { + $validator = new \JsonSchema\Validator(); + $input = is_array($input) ? \JsonSchema\Validator::arrayToObjectRecursive($input) : $input; + $validator->validate($input, self::$internalValidationSchema); + + if (!$validator->isValid() && !$return) { + $errors = array_map(function(array $e): string { + return $e["property"] . ": " . $e["message"]; + }, $validator->getErrors()); + throw new \InvalidArgumentException(join(", ", $errors)); + } + + return $validator->isValid(); + } + + public function __clone() + { + } +} \ No newline at end of file diff --git a/tests/Generator/Fixtures/PropsWithAdditionalProps/schema.yaml b/tests/Generator/Fixtures/PropsWithAdditionalProps/schema.yaml new file mode 100644 index 00000000..32231d1e --- /dev/null +++ b/tests/Generator/Fixtures/PropsWithAdditionalProps/schema.yaml @@ -0,0 +1,8 @@ +type: object +required: + - reason +properties: + reason: + type: string +additionalProperties: + type: string