diff --git a/src/JsonSchemaCodeGenerator.php b/src/JsonSchemaCodeGenerator.php new file mode 100644 index 0000000..cf1ff1b --- /dev/null +++ b/src/JsonSchemaCodeGenerator.php @@ -0,0 +1,634 @@ +|string $jsonSchema JSON Schema as an associative array or JSON string + * + * @throws \InvalidArgumentException if the schema is invalid + */ + public function generate(array|string $jsonSchema): string + { + if (\is_string($jsonSchema)) { + $decoded = json_decode($jsonSchema, true); + + if (!\is_array($decoded)) { + throw new \InvalidArgumentException('Invalid JSON string provided'); + } + + /** @var array $decoded */ + $jsonSchema = $decoded; + } + + return $this->generateSchema($jsonSchema); + } + + /** + * @param array $schema + */ + private function generateSchema(array $schema): string + { + if (isset($schema['$ref'])) { + throw new \InvalidArgumentException('$ref is not supported. Please dereference the schema first.'); + } + + if (isset($schema['const'])) { + return $this->generateConst($schema); + } + + if (isset($schema['enum'])) { + return $this->generateEnum($schema); + } + + // Handle oneOf / anyOf as union + if (isset($schema['oneOf']) && \is_array($schema['oneOf'])) { + /** @var array> $oneOf */ + $oneOf = $schema['oneOf']; + + return $this->generateUnion($oneOf, $schema); + } + + if (isset($schema['anyOf']) && \is_array($schema['anyOf'])) { + /** @var array> $anyOf */ + $anyOf = $schema['anyOf']; + + return $this->generateUnion($anyOf, $schema); + } + + // Handle allOf by merging into a single object schema + if (isset($schema['allOf']) && \is_array($schema['allOf'])) { + /** @var array> $allOf */ + $allOf = $schema['allOf']; + + return $this->generateAllOf($allOf, $schema); + } + + $type = $schema['type'] ?? null; + + // Handle type as array (e.g., ["string", "null"]) + if (\is_array($type)) { + /** @var array $type */ + return $this->generateMultiType($type, $schema); + } + + if (null !== $type && !\is_string($type)) { + throw new \InvalidArgumentException('Invalid type value in schema'); + } + + return match ($type) { + 'string' => $this->generateString($schema), + 'integer' => $this->generateInteger($schema), + 'number' => $this->generateNumber($schema), + 'boolean' => $this->generateBoolean($schema), + 'array' => $this->generateArray($schema), + 'object' => $this->generateObject($schema), + 'null' => $this->generateNull($schema), + null => $this->generateFallback($schema), + default => throw new \InvalidArgumentException(\sprintf('Unsupported JSON Schema type: %s', $type)), + }; + } + + /** + * @param array $schema + */ + private function generateConst(array $schema): string + { + return '$p->const('.$this->exportValue($schema['const']).')'; + } + + /** + * @param array $schema + */ + private function generateEnum(array $schema): string + { + $values = $schema['enum']; + + if (!\is_array($values) || 0 === \count($values)) { + throw new \InvalidArgumentException('Enum must be a non-empty array'); + } + + $hasNull = \in_array(null, $values, true); + + /** @var array $nonNullValues */ + $nonNullValues = array_values(array_filter($values, static fn (mixed $v): bool => null !== $v)); + + if (0 === \count($nonNullValues)) { + throw new \InvalidArgumentException('Enum must have at least one non null value'); + } + + if (1 === \count($nonNullValues)) { + $code = '$p->const('.$this->exportValue($nonNullValues[0]).')'; + + if ($hasNull) { + $code .= '->nullable()'; + } + + return $code; + } + + // Multiple values: union of literals + $literals = array_map( + fn (mixed $v): string => '$p->const('.$this->exportValue($v).')', + $nonNullValues, + ); + + $code = '$p->union(['.implode(', ', $literals).'])'; + + if ($hasNull) { + $code .= '->nullable()'; + } + + return $code; + } + + /** + * @param array> $schemas + * @param array $parentSchema + */ + private function generateUnion(array $schemas, array $parentSchema): string + { + // Check if this is a discriminated union (all objects with a common discriminator field) + $discriminator = $this->detectDiscriminator($schemas); + + if (null !== $discriminator) { + return $this->generateDiscriminatedUnion($schemas, $discriminator, $parentSchema); + } + + // Filter out null type schemas + $hasNull = false; + + /** @var array> $nonNullSchemas */ + $nonNullSchemas = []; + + foreach ($schemas as $subSchema) { + if (isset($subSchema['type']) && 'null' === $subSchema['type']) { + $hasNull = true; + } else { + $nonNullSchemas[] = $subSchema; + } + } + + if (1 === \count($nonNullSchemas)) { + $code = $this->generateSchema($nonNullSchemas[0]); + + if ($hasNull) { + $code .= '->nullable()'; + } + + return $code; + } + + $subCodes = array_map(fn (array $s): string => $this->generateSchema($s), $nonNullSchemas); + + $code = '$p->union(['.implode(', ', $subCodes).'])'; + + if ($hasNull) { + $code .= '->nullable()'; + } + + return $code; + } + + /** + * @param array> $schemas + * + * @return null|string the discriminator field name, or null + */ + private function detectDiscriminator(array $schemas): ?string + { + if (\count($schemas) < 2) { + return null; + } + + // All schemas must be objects + foreach ($schemas as $schema) { + $type = $schema['type'] ?? null; + + if ('object' !== $type) { + return null; + } + } + + // Find common required fields with const or enum of one value + /** @var array $candidates */ + $candidates = []; + + foreach ($schemas as $schema) { + /** @var array> $properties */ + $properties = isset($schema['properties']) && \is_array($schema['properties']) + ? $schema['properties'] + : []; + + /** @var array $required */ + $required = isset($schema['required']) && \is_array($schema['required']) + ? $schema['required'] + : []; + + foreach ($required as $fieldName) { + if (!isset($properties[$fieldName])) { + continue; + } + + /** @var array $fieldSchema */ + $fieldSchema = $properties[$fieldName]; + $isDiscriminator = false; + + if (\array_key_exists('const', $fieldSchema)) { + $isDiscriminator = true; + } elseif (isset($fieldSchema['enum']) && \is_array($fieldSchema['enum']) && 1 === \count($fieldSchema['enum'])) { + $isDiscriminator = true; + } + + if ($isDiscriminator) { + if (!isset($candidates[$fieldName])) { + $candidates[$fieldName] = 0; + } + + ++$candidates[$fieldName]; + } + } + } + + // The discriminator field must appear in ALL schemas + foreach ($candidates as $fieldName => $count) { + if ($count === \count($schemas)) { + return (string) $fieldName; + } + } + + return null; + } + + /** + * @param array> $schemas + * @param array $parentSchema + */ + private function generateDiscriminatedUnion(array $schemas, string $discriminator, array $parentSchema): string + { + $objectCodes = array_map(fn (array $s): string => $this->generateObject($s), $schemas); + + return '$p->discriminatedUnion(['.implode(', ', $objectCodes).'], ' + .$this->exportValue($discriminator).')'; + } + + /** + * @param array> $schemas + * @param array $parentSchema + */ + private function generateAllOf(array $schemas, array $parentSchema): string + { + // Merge all schemas into one object schema + /** @var array> $mergedProperties */ + $mergedProperties = []; + + /** @var array $mergedRequired */ + $mergedRequired = []; + + foreach ($schemas as $subSchema) { + if (isset($subSchema['properties']) && \is_array($subSchema['properties'])) { + /** @var array> $props */ + $props = $subSchema['properties']; + $mergedProperties = array_merge($mergedProperties, $props); + } + + if (isset($subSchema['required']) && \is_array($subSchema['required'])) { + /** @var array $req */ + $req = $subSchema['required']; + $mergedRequired = array_merge($mergedRequired, $req); + } + } + + /** @var array $merged */ + $merged = [ + 'type' => 'object', + 'properties' => $mergedProperties, + 'required' => array_values(array_unique($mergedRequired)), + ]; + + // Carry over additionalProperties if set on parent + if (\array_key_exists('additionalProperties', $parentSchema)) { + $merged['additionalProperties'] = $parentSchema['additionalProperties']; + } + + return $this->generateObject($merged); + } + + /** + * @param array $types + * @param array $schema + */ + private function generateMultiType(array $types, array $schema): string + { + $hasNull = \in_array('null', $types, true); + $nonNullTypes = array_values(array_filter($types, static fn (string $t): bool => 'null' !== $t)); + + if (1 === \count($nonNullTypes)) { + $singleSchema = $schema; + $singleSchema['type'] = $nonNullTypes[0]; + $code = $this->generateSchema($singleSchema); + + if ($hasNull) { + $code .= '->nullable()'; + } + + return $code; + } + + // Multiple non-null types: union + $subCodes = []; + + foreach ($nonNullTypes as $t) { + $subSchema = $schema; + $subSchema['type'] = $t; + $subCodes[] = $this->generateSchema($subSchema); + } + + $code = '$p->union(['.implode(', ', $subCodes).'])'; + + if ($hasNull) { + $code .= '->nullable()'; + } + + return $code; + } + + /** + * @param array $schema + */ + private function generateString(array $schema): string + { + $code = '$p->string()'; + + if (isset($schema['minLength']) && (\is_int($schema['minLength']) || \is_float($schema['minLength']))) { + $code .= '->minLength('.(int) $schema['minLength'].')'; + } + + if (isset($schema['maxLength']) && (\is_int($schema['maxLength']) || \is_float($schema['maxLength']))) { + $code .= '->maxLength('.(int) $schema['maxLength'].')'; + } + + if (isset($schema['pattern']) && \is_string($schema['pattern'])) { + $code .= '->pattern('.$this->exportValue('/'.$schema['pattern'].'/').')'; + } + + if (isset($schema['format']) && \is_string($schema['format'])) { + $code = $this->applyStringFormat($code, $schema['format']); + } + + return $code; + } + + private function applyStringFormat(string $code, string $format): string + { + return match ($format) { + 'email' => $code.'->email()', + 'uri', 'url' => $code.'->uri()', + 'ipv4' => $code.'->ipV4()', + 'ipv6' => $code.'->ipV6()', + 'uuid' => $code.'->uuid()', + 'hostname' => $code.'->hostname()', + 'date-time' => $code.'->toDateTime()', + default => $code.' /* unsupported format: '.$format.' */', + }; + } + + /** + * @param array $schema + */ + private function generateInteger(array $schema): string + { + $code = '$p->int()'; + + return $this->applyNumericConstraints($code, $schema, true); + } + + /** + * @param array $schema + */ + private function generateNumber(array $schema): string + { + $code = '$p->float()'; + + return $this->applyNumericConstraints($code, $schema, false); + } + + /** + * @param array $schema + */ + private function applyNumericConstraints(string $code, array $schema, bool $isInt): string + { + if (isset($schema['minimum']) && (\is_int($schema['minimum']) || \is_float($schema['minimum']))) { + $val = $isInt ? (int) $schema['minimum'] : (float) $schema['minimum']; + if (isset($schema['exclusiveMinimum']) && true === $schema['exclusiveMinimum']) { + $code .= '->exclusiveMinimum('.$this->exportValue($val).')'; + } else { + $code .= '->minimum('.$this->exportValue($val).')'; + } + } elseif (isset($schema['exclusiveMinimum']) && (\is_int($schema['exclusiveMinimum']) || \is_float($schema['exclusiveMinimum']))) { + $val = $isInt ? (int) $schema['exclusiveMinimum'] : (float) $schema['exclusiveMinimum']; + $code .= '->exclusiveMinimum('.$this->exportValue($val).')'; + } + + if (isset($schema['maximum']) && (\is_int($schema['maximum']) || \is_float($schema['maximum']))) { + $val = $isInt ? (int) $schema['maximum'] : (float) $schema['maximum']; + if (isset($schema['exclusiveMaximum']) && true === $schema['exclusiveMaximum']) { + $code .= '->exclusiveMaximum('.$this->exportValue($val).')'; + } else { + $code .= '->maximum('.$this->exportValue($val).')'; + } + } elseif (isset($schema['exclusiveMaximum']) && (\is_int($schema['exclusiveMaximum']) || \is_float($schema['exclusiveMaximum']))) { + $val = $isInt ? (int) $schema['exclusiveMaximum'] : (float) $schema['exclusiveMaximum']; + $code .= '->exclusiveMaximum('.$this->exportValue($val).')'; + } + + return $code; + } + + /** + * @param array $schema + */ + private function generateBoolean(array $schema): string + { + return '$p->bool()'; + } + + /** + * @param array $schema + */ + private function generateArray(array $schema): string + { + if (isset($schema['prefixItems']) && \is_array($schema['prefixItems'])) { + return $this->generateTuple($schema); + } + + if (isset($schema['items']) && \is_array($schema['items']) && [] !== $schema['items'] && array_is_list($schema['items'])) { + return $this->generateTuple($schema); + } + + /** @var array $itemSchema */ + $itemSchema = isset($schema['items']) && \is_array($schema['items']) ? $schema['items'] : []; + + if ([] === $itemSchema) { + // No items schema defined: default to string + $itemCode = '$p->string()'; + } else { + $itemCode = $this->generateSchema($itemSchema); + } + + $code = '$p->array('.$itemCode.')'; + + if (isset($schema['minItems']) && (\is_int($schema['minItems']) || \is_float($schema['minItems']))) { + $code .= '->minItems('.(int) $schema['minItems'].')'; + } + + if (isset($schema['maxItems']) && (\is_int($schema['maxItems']) || \is_float($schema['maxItems']))) { + $code .= '->maxItems('.(int) $schema['maxItems'].')'; + } + + return $code; + } + + /** + * @param array $schema + */ + private function generateTuple(array $schema): string + { + /** @var array> $items */ + $items = isset($schema['prefixItems']) && \is_array($schema['prefixItems']) + ? $schema['prefixItems'] + : (\is_array($schema['items'] ?? null) ? $schema['items'] : []); + + $itemCodes = array_map(fn (array $item): string => $this->generateSchema($item), $items); + + return '$p->tuple(['.implode(', ', $itemCodes).'])'; + } + + /** + * @param array $schema + */ + private function generateObject(array $schema): string + { + /** @var array> $properties */ + $properties = isset($schema['properties']) && \is_array($schema['properties']) + ? $schema['properties'] + : []; + + /** @var array $required */ + $required = isset($schema['required']) && \is_array($schema['required']) + ? $schema['required'] + : []; + + $additionalProperties = $schema['additionalProperties'] ?? null; + + // If no properties defined but additionalProperties has a schema, use record + if ([] === $properties && \is_array($additionalProperties)) { + /** @var array $additionalPropertiesSchema */ + $additionalPropertiesSchema = $additionalProperties; + + return '$p->record('.$this->generateSchema($additionalPropertiesSchema).')'; + } + + if ([] === $properties && (true === $additionalProperties || null === $additionalProperties)) { + return '$p->record($p->string())'; + } + + $fieldCodes = []; + + foreach ($properties as $fieldName => $fieldSchema) { + $fieldCode = $this->generateSchema($fieldSchema); + + if (!\in_array($fieldName, $required, true)) { + $fieldCode .= '->nullable()'; + } + + $fieldCodes[] = $this->exportValue($fieldName).' => '.$fieldCode; + } + + $code = '$p->object(['.implode(', ', $fieldCodes).'])'; + + // Build list of optional (non-required) fields + /** @var array $optionalFields */ + $optionalFields = []; + + foreach (array_keys($properties) as $fieldName) { + if (!\in_array($fieldName, $required, true)) { + $optionalFields[] = $fieldName; + } + } + + if ([] !== $optionalFields) { + $exportedOptionals = array_map(fn (string $f): string => $this->exportValue($f), $optionalFields); + $code .= '->optional(['.implode(', ', $exportedOptionals).'])'; + } + + if (false === $additionalProperties) { + $code .= '->strict()'; + } + + return $code; + } + + /** + * @param array $schema + */ + private function generateNull(array $schema): string + { + return '$p->string()->nullable()->default(null)'; + } + + /** + * Fallback when no type is specified and no composition keywords are found. + * + * @param array $schema + */ + private function generateFallback(array $schema): string + { + // If there are properties, treat as object + if (isset($schema['properties'])) { + $schema['type'] = 'object'; + + return $this->generateObject($schema); + } + + // Default: accept any string + return '$p->string()'; + } + + private function exportValue(mixed $value): string + { + if (\is_string($value)) { + return "'".addcslashes($value, "'\\")."'"; + } + + if (\is_int($value)) { + return (string) $value; + } + + if (\is_float($value)) { + $str = (string) $value; + + // Ensure float representation + if (!str_contains($str, '.') && !str_contains($str, 'E') && !str_contains($str, 'e')) { + $str .= '.0'; + } + + return $str; + } + + if (\is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (null === $value) { + return 'null'; + } + + return var_export($value, true); + } +} diff --git a/tests/Unit/JsonSchemaCodeGeneratorTest.php b/tests/Unit/JsonSchemaCodeGeneratorTest.php new file mode 100644 index 0000000..4bcb6f4 --- /dev/null +++ b/tests/Unit/JsonSchemaCodeGeneratorTest.php @@ -0,0 +1,919 @@ +generate(['type' => 'string']); + + self::assertSame('$p->string()', $code); + } + + public function testStringWithMinLength(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'minLength' => 3]); + + self::assertSame('$p->string()->minLength(3)', $code); + } + + public function testStringWithMaxLength(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'maxLength' => 100]); + + self::assertSame('$p->string()->maxLength(100)', $code); + } + + public function testStringWithMinAndMaxLength(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'minLength' => 1, 'maxLength' => 255]); + + self::assertSame('$p->string()->minLength(1)->maxLength(255)', $code); + } + + public function testStringWithPattern(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'pattern' => '^[a-z]+$']); + + self::assertSame("\$p->string()->pattern('/^[a-z]+$/')", $code); + } + + public function testStringWithEmailFormat(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'format' => 'email']); + + self::assertSame('$p->string()->email()', $code); + } + + public function testStringWithUriFormat(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'format' => 'uri']); + + self::assertSame('$p->string()->uri()', $code); + } + + public function testStringWithIpv4Format(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'format' => 'ipv4']); + + self::assertSame('$p->string()->ipV4()', $code); + } + + public function testStringWithIpv6Format(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'format' => 'ipv6']); + + self::assertSame('$p->string()->ipV6()', $code); + } + + public function testStringWithUuidFormat(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'format' => 'uuid']); + + self::assertSame('$p->string()->uuid()', $code); + } + + public function testStringWithHostnameFormat(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'format' => 'hostname']); + + self::assertSame('$p->string()->hostname()', $code); + } + + public function testStringWithDateTimeFormat(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'format' => 'date-time']); + + self::assertSame('$p->string()->toDateTime()', $code); + } + + public function testStringWithUnsupportedFormat(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'format' => 'binary']); + + self::assertSame('$p->string() /* unsupported format: binary */', $code); + } + + public function testInteger(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'integer']); + + self::assertSame('$p->int()', $code); + } + + public function testIntegerWithMinimum(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'integer', 'minimum' => 0]); + + self::assertSame('$p->int()->minimum(0)', $code); + } + + public function testIntegerWithMaximum(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'integer', 'maximum' => 100]); + + self::assertSame('$p->int()->maximum(100)', $code); + } + + public function testIntegerWithExclusiveMinimum(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'integer', 'exclusiveMinimum' => 0]); + + self::assertSame('$p->int()->exclusiveMinimum(0)', $code); + } + + public function testIntegerWithExclusiveMaximum(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'integer', 'exclusiveMaximum' => 100]); + + self::assertSame('$p->int()->exclusiveMaximum(100)', $code); + } + + public function testIntegerWithAllConstraints(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 99, + ]); + + self::assertSame('$p->int()->minimum(1)->maximum(99)', $code); + } + + public function testIntegerWithMinimumAndExclusiveMinimumTrue(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'integer', 'minimum' => 0, 'exclusiveMinimum' => true]); + + self::assertSame('$p->int()->exclusiveMinimum(0)', $code); + } + + public function testIntegerWithMinimumAndExclusiveMinimumFalse(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'integer', 'minimum' => 0, 'exclusiveMinimum' => false]); + + self::assertSame('$p->int()->minimum(0)', $code); + } + + public function testIntegerWithMaximumAndExclusiveMaximumTrue(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'integer', 'maximum' => 100, 'exclusiveMaximum' => true]); + + self::assertSame('$p->int()->exclusiveMaximum(100)', $code); + } + + public function testIntegerWithMaximumAndExclusiveMaximumFalse(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'integer', 'maximum' => 100, 'exclusiveMaximum' => false]); + + self::assertSame('$p->int()->maximum(100)', $code); + } + + public function testNumber(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number']); + + self::assertSame('$p->float()', $code); + } + + public function testNumberWithMinimum(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number', 'minimum' => 0.5]); + + self::assertSame('$p->float()->minimum(0.5)', $code); + } + + public function testNumberWithMaximum(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number', 'maximum' => 99.9]); + + self::assertSame('$p->float()->maximum(99.9)', $code); + } + + public function testNumberWithExclusiveMinimum(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number', 'exclusiveMinimum' => 0.0]); + + self::assertSame('$p->float()->exclusiveMinimum(0.0)', $code); + } + + public function testNumberWithExclusiveMaximum(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number', 'exclusiveMaximum' => 100.0]); + + self::assertSame('$p->float()->exclusiveMaximum(100.0)', $code); + } + + public function testNumberWithMinimumAndExclusiveMinimumTrue(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number', 'minimum' => 0.5, 'exclusiveMinimum' => true]); + + self::assertSame('$p->float()->exclusiveMinimum(0.5)', $code); + } + + public function testNumberWithMinimumAndExclusiveMinimumFalse(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number', 'minimum' => 0.5, 'exclusiveMinimum' => false]); + + self::assertSame('$p->float()->minimum(0.5)', $code); + } + + public function testNumberWithMaximumAndExclusiveMaximumTrue(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number', 'maximum' => 99.9, 'exclusiveMaximum' => true]); + + self::assertSame('$p->float()->exclusiveMaximum(99.9)', $code); + } + + public function testNumberWithMaximumAndExclusiveMaximumFalse(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number', 'maximum' => 99.9, 'exclusiveMaximum' => false]); + + self::assertSame('$p->float()->maximum(99.9)', $code); + } + + public function testBoolean(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'boolean']); + + self::assertSame('$p->bool()', $code); + } + + public function testNull(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'null']); + + self::assertSame('$p->string()->nullable()->default(null)', $code); + } + + public function testArrayBasic(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'array', + 'items' => ['type' => 'string'], + ]); + + self::assertSame('$p->array($p->string())', $code); + } + + public function testArrayOfIntegers(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'array', + 'items' => ['type' => 'integer'], + ]); + + self::assertSame('$p->array($p->int())', $code); + } + + public function testArrayWithMinItems(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'minItems' => 1, + ]); + + self::assertSame('$p->array($p->string())->minItems(1)', $code); + } + + public function testArrayWithMaxItems(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'maxItems' => 10, + ]); + + self::assertSame('$p->array($p->string())->maxItems(10)', $code); + } + + public function testArrayWithMinAndMaxItems(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'minItems' => 1, + 'maxItems' => 10, + ]); + + self::assertSame('$p->array($p->string())->minItems(1)->maxItems(10)', $code); + } + + public function testArrayWithNoItems(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'array']); + + self::assertSame('$p->array($p->string())', $code); + } + + public function testArrayOfObjects(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + 'required' => ['name'], + ], + ]); + + self::assertSame("\$p->array(\$p->object(['name' => \$p->string()]))", $code); + } + + public function testTupleWithPrefixItems(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'array', + 'prefixItems' => [ + ['type' => 'number'], + ['type' => 'number'], + ], + ]); + + self::assertSame('$p->tuple([$p->float(), $p->float()])', $code); + } + + public function testObjectBasic(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'age' => ['type' => 'integer'], + ], + 'required' => ['name', 'age'], + ]); + + self::assertSame("\$p->object(['name' => \$p->string(), 'age' => \$p->int()])", $code); + } + + public function testObjectWithOptionalFields(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'nickname' => ['type' => 'string'], + ], + 'required' => ['name'], + ]); + + self::assertSame( + "\$p->object(['name' => \$p->string(), 'nickname' => \$p->string()->nullable()])->optional(['nickname'])", + $code, + ); + } + + public function testObjectStrict(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + 'required' => ['name'], + 'additionalProperties' => false, + ]); + + self::assertSame("\$p->object(['name' => \$p->string()])->strict()", $code); + } + + public function testObjectAsRecord(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'object', + 'additionalProperties' => ['type' => 'string'], + ]); + + self::assertSame('$p->record($p->string())', $code); + } + + public function testObjectAsRecordWithIntValues(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'object', + 'additionalProperties' => ['type' => 'integer'], + ]); + + self::assertSame('$p->record($p->int())', $code); + } + + public function testObjectNested(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'object', + 'properties' => [ + 'address' => [ + 'type' => 'object', + 'properties' => [ + 'street' => ['type' => 'string'], + 'city' => ['type' => 'string'], + ], + 'required' => ['street', 'city'], + ], + ], + 'required' => ['address'], + ]); + + self::assertSame( + "\$p->object(['address' => \$p->object(['street' => \$p->string(), 'city' => \$p->string()])])", + $code, + ); + } + + public function testConst(): void + { + $generator = new JsonSchemaCodeGenerator(); + + self::assertSame("\$p->const('active')", $generator->generate(['const' => 'active'])); + self::assertSame('$p->const(42)', $generator->generate(['const' => 42])); + self::assertSame('$p->const(true)', $generator->generate(['const' => true])); + } + + public function testEnumStrings(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['enum' => ['active', 'inactive', 'pending']]); + + self::assertSame( + "\$p->union([\$p->const('active'), \$p->const('inactive'), \$p->const('pending')])", + $code, + ); + } + + public function testEnumSingleValue(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['enum' => ['only']]); + + self::assertSame("\$p->const('only')", $code); + } + + public function testEnumWithNull(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['enum' => ['active', 'inactive', null]]); + + self::assertSame( + "\$p->union([\$p->const('active'), \$p->const('inactive')])->nullable()", + $code, + ); + } + + public function testEnumIntegers(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['enum' => [1, 2, 3]]); + + self::assertSame('$p->union([$p->const(1), $p->const(2), $p->const(3)])', $code); + } + + public function testOneOfSimple(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'oneOf' => [ + ['type' => 'string'], + ['type' => 'integer'], + ], + ]); + + self::assertSame('$p->union([$p->string(), $p->int()])', $code); + } + + public function testOneOfWithNull(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'oneOf' => [ + ['type' => 'string'], + ['type' => 'null'], + ], + ]); + + self::assertSame('$p->string()->nullable()', $code); + } + + public function testAnyOfSimple(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'anyOf' => [ + ['type' => 'string'], + ['type' => 'integer'], + ], + ]); + + self::assertSame('$p->union([$p->string(), $p->int()])', $code); + } + + public function testNullableTypeArray(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => ['string', 'null']]); + + self::assertSame('$p->string()->nullable()', $code); + } + + public function testMultiType(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => ['string', 'integer']]); + + self::assertSame('$p->union([$p->string(), $p->int()])', $code); + } + + public function testMultiTypeWithNull(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => ['string', 'integer', 'null']]); + + self::assertSame('$p->union([$p->string(), $p->int()])->nullable()', $code); + } + + public function testAllOfMerge(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'allOf' => [ + [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']], + 'required' => ['id'], + ], + [ + 'type' => 'object', + 'properties' => ['name' => ['type' => 'string']], + 'required' => ['name'], + ], + ], + ]); + + self::assertSame("\$p->object(['id' => \$p->int(), 'name' => \$p->string()])", $code); + } + + public function testDiscriminatedUnion(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'oneOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'type' => ['const' => 'email'], + 'address' => ['type' => 'string'], + ], + 'required' => ['type', 'address'], + ], + [ + 'type' => 'object', + 'properties' => [ + 'type' => ['const' => 'phone'], + 'number' => ['type' => 'string'], + ], + 'required' => ['type', 'number'], + ], + ], + ]); + + self::assertSame( + "\$p->discriminatedUnion([\$p->object(['type' => \$p->const('email'), 'address' => \$p->string()]), " + ."\$p->object(['type' => \$p->const('phone'), 'number' => \$p->string()])], 'type')", + $code, + ); + } + + public function testFromJsonString(): void + { + $generator = new JsonSchemaCodeGenerator(); + $json = '{"type": "string", "minLength": 1}'; + $code = $generator->generate($json); + + self::assertSame('$p->string()->minLength(1)', $code); + } + + public function testInvalidJsonString(): void + { + $generator = new JsonSchemaCodeGenerator(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid JSON string provided'); + + $generator->generate('not valid json'); + } + + public function testUnsupportedType(): void + { + $generator = new JsonSchemaCodeGenerator(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported JSON Schema type: foobar'); + + $generator->generate(['type' => 'foobar']); + } + + public function testRefThrowsException(): void + { + $generator = new JsonSchemaCodeGenerator(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('$ref is not supported'); + + $generator->generate(['$ref' => '#/definitions/Foo']); + } + + public function testEmptyEnumThrowsException(): void + { + $generator = new JsonSchemaCodeGenerator(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Enum must be a non-empty array'); + + $generator->generate(['enum' => []]); + } + + public function testComplexSchema(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer', 'minimum' => 1], + 'name' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 100], + 'email' => ['type' => 'string', 'format' => 'email'], + 'age' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 150], + 'tags' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'minItems' => 1, + ], + 'metadata' => [ + 'type' => 'object', + 'additionalProperties' => ['type' => 'string'], + ], + ], + 'required' => ['id', 'name', 'email'], + 'additionalProperties' => false, + ]); + + self::assertSame( + "\$p->object(['id' => \$p->int()->minimum(1), 'name' => \$p->string()->minLength(1)->maxLength(100), " + ."'email' => \$p->string()->email(), 'age' => \$p->int()->minimum(0)->maximum(150)->nullable(), " + ."'tags' => \$p->array(\$p->string())->minItems(1)->nullable(), 'metadata' => \$p->record(\$p->string())->nullable()])" + ."->optional(['age', 'tags', 'metadata'])->strict()", + $code, + ); + } + + public function testSchemaWithNoType(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([]); + + self::assertSame('$p->string()', $code); + } + + public function testSchemaWithNoTypeButProperties(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'properties' => [ + 'name' => ['type' => 'string'], + ], + 'required' => ['name'], + ]); + + self::assertSame("\$p->object(['name' => \$p->string()])", $code); + } + + public function testObjectEmptyPropertiesWithAdditionalPropertiesTrue(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'object', + 'additionalProperties' => true, + ]); + + self::assertSame('$p->record($p->string())', $code); + } + + public function testStringWithMultipleConstraints(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'string', + 'minLength' => 3, + 'maxLength' => 50, + 'format' => 'email', + ]); + + self::assertSame('$p->string()->minLength(3)->maxLength(50)->email()', $code); + } + + public function testNestedArraysOfObjects(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'array', + 'items' => [ + 'type' => 'array', + 'items' => ['type' => 'integer'], + ], + ]); + + self::assertSame('$p->array($p->array($p->int()))', $code); + } + + public function testDiscriminatedUnionWithEnum(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'oneOf' => [ + [ + 'type' => 'object', + 'properties' => [ + 'kind' => ['enum' => ['circle']], + 'radius' => ['type' => 'number'], + ], + 'required' => ['kind', 'radius'], + ], + [ + 'type' => 'object', + 'properties' => [ + 'kind' => ['enum' => ['square']], + 'side' => ['type' => 'number'], + ], + 'required' => ['kind', 'side'], + ], + ], + ]); + + self::assertSame( + "\$p->discriminatedUnion([\$p->object(['kind' => \$p->const('circle'), 'radius' => \$p->float()]), " + ."\$p->object(['kind' => \$p->const('square'), 'side' => \$p->float()])], 'kind')", + $code, + ); + } + + public function testNullableTypeArrayWithConstraints(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => ['string', 'null'], + 'minLength' => 1, + ]); + + self::assertSame('$p->string()->minLength(1)->nullable()', $code); + } + + public function testStringWithUrlFormat(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'string', 'format' => 'url']); + + self::assertSame('$p->string()->uri()', $code); + } + + public function testAllOfWithOptionalFields(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'allOf' => [ + [ + 'type' => 'object', + 'properties' => ['id' => ['type' => 'integer']], + 'required' => ['id'], + ], + [ + 'type' => 'object', + 'properties' => ['nickname' => ['type' => 'string']], + ], + ], + ]); + + self::assertSame( + "\$p->object(['id' => \$p->int(), 'nickname' => \$p->string()->nullable()])->optional(['nickname'])", + $code, + ); + } + + public function testObjectAllFieldsOptional(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate([ + 'type' => 'object', + 'properties' => [ + 'a' => ['type' => 'string'], + 'b' => ['type' => 'integer'], + ], + ]); + + self::assertSame( + "\$p->object(['a' => \$p->string()->nullable(), 'b' => \$p->int()->nullable()])->optional(['a', 'b'])", + $code, + ); + } + + public function testRefInsideNestedSchemaThrows(): void + { + $generator = new JsonSchemaCodeGenerator(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('$ref is not supported'); + + $generator->generate([ + 'type' => 'object', + 'properties' => [ + 'child' => ['$ref' => '#/definitions/Child'], + ], + 'required' => ['child'], + ]); + } + + public function testEnumOnlyNull(): void + { + $generator = new JsonSchemaCodeGenerator(); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Enum must have at leas one non null value'); + + $generator->generate(['enum' => [null]]); + } + + public function testObjectWithNoProperties(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'object']); + + self::assertSame('$p->record($p->string())', $code); + } + + public function testNumberIntegerWholeValue(): void + { + $generator = new JsonSchemaCodeGenerator(); + $code = $generator->generate(['type' => 'number', 'minimum' => 0]); + + self::assertSame('$p->float()->minimum(0.0)', $code); + } +}