diff --git a/src/Codegen/Constraints/BaseBuilder.php b/src/Codegen/Constraints/BaseBuilder.php index 7e29a22..e0068ca 100644 --- a/src/Codegen/Constraints/BaseBuilder.php +++ b/src/Codegen/Constraints/BaseBuilder.php @@ -108,56 +108,62 @@ protected function addHackEnumConstraintCheck(HackBuilder $hb): void { if (!Shapes::keyExists($schema, 'hackEnum')) { return; } + $generateHackEnum = $schema['generateHackEnum'] ?? false; + if (!$generateHackEnum) { - try { - $rc = new \ReflectionClass($schema['hackEnum']); - } catch (\ReflectionException $e) { - throw new \Exception(Str\format("Hack enum '%s' does not exist", $schema['hackEnum'])); - } - - invariant($rc->isEnum(), "'%s' is not an enum", $schema['hackEnum']); - - $schema_type = $schema['type'] ?? null; - $hack_enum_values = keyset[]; - foreach ($rc->getConstants() as $hack_enum_value) { - if ($schema_type === TSchemaType::INTEGER_T) { - $hack_enum_value = $hack_enum_value ?as int; - } else { - $hack_enum_value = $hack_enum_value ?as string; + try { + $rc = new \ReflectionClass($schema['hackEnum']); + } catch (\ReflectionException $e) { + throw new \Exception(Str\format("Hack enum '%s' does not exist", $schema['hackEnum'])); } - invariant( - $hack_enum_value is nonnull, - "'%s' must contain only values of type %s", - $rc->getName(), - $schema_type === TSchemaType::INTEGER_T ? 'int' : 'string', - ); - $hack_enum_values[] = $hack_enum_value; - } - if (Shapes::keyExists($schema, 'enum')) { - // If both `enum` and `hackEnum` are specified, assert that `enum` is a subset of - // `hackEnum` values. Any value not also in `hackEnum` can't be valid. - foreach ($schema['enum'] as $enum_value) { - invariant( - $enum_value is string, - "Enum value '%s' is not a valid value for '%s'", - \print_r($enum_value, true), - $rc->getName(), - ); + invariant($rc->isEnum(), "'%s' is not an enum", $schema['hackEnum']); + + $schema_type = $schema['type'] ?? null; + $hack_enum_values = keyset[]; + foreach ($rc->getConstants() as $hack_enum_value) { + if ($schema_type === TSchemaType::INTEGER_T) { + $hack_enum_value = $hack_enum_value ?as int; + } else { + $hack_enum_value = $hack_enum_value ?as string; + } invariant( - C\contains_key($hack_enum_values, $enum_value), - "Enum value '%s' is unexpectedly not present in '%s'", - \print_r($enum_value, true), + $hack_enum_value is nonnull, + "'%s' must contain only values of type %s", $rc->getName(), + $schema_type === TSchemaType::INTEGER_T ? 'int' : 'string', ); + $hack_enum_values[] = $hack_enum_value; + } + + if (Shapes::keyExists($schema, 'enum')) { + // If both `enum` and `hackEnum` are specified, assert that `enum` is a subset of + // `hackEnum` values. Any value not also in `hackEnum` can't be valid. + foreach ($schema['enum'] as $enum_value) { + invariant( + $enum_value is string, + "Enum value '%s' is not a valid value for '%s'", + \print_r($enum_value, true), + $rc->getName(), + ); + invariant( + C\contains_key($hack_enum_values, $enum_value), + "Enum value '%s' is unexpectedly not present in '%s'", + \print_r($enum_value, true), + $rc->getName(), + ); + } } + $enum_name = Str\format('\%s::class', $rc->getName()); + } else { + $enum_name = $schema['hackEnum'].'::class'; } $hb->addMultilineCall( '$typed = Constraints\HackEnumConstraint::check', vec[ '$typed', - Str\format('\%s::class', $rc->getName()), + $enum_name, '$pointer', ], ); diff --git a/src/Codegen/Constraints/StringBuilder.php b/src/Codegen/Constraints/StringBuilder.php index d69183a..558eb57 100644 --- a/src/Codegen/Constraints/StringBuilder.php +++ b/src/Codegen/Constraints/StringBuilder.php @@ -12,6 +12,7 @@ ?'minLength' => int, ?'enum' => vec, ?'hackEnum' => string, + ?'generateHackEnum' => bool, ?'pattern' => string, ?'format' => string, ?'sanitize' => shape( @@ -59,8 +60,28 @@ public function build(): this { } $enum = $this->getEnumCodegenProperty(); + $generateHackEnum = $this->typed_schema['generateHackEnum'] ?? false; if ($enum is nonnull) { - $properties[] = $enum; + if ($generateHackEnum) { + $enum = $this->typed_schema['enum'] ?? vec[]; + $factory = $this->ctx->getHackCodegenFactory(); + $members = \HH\Lib\Vec\map( + $enum, + $member ==> $factory->codegenEnumMember(Str\uppercase($member)) + ->setValue($member, HackBuilderValues::export()), + ); + $enumName = $this->typed_schema['hackEnum'] ?? null; + invariant($enumName is string, 'hackEnum is required when generating hack enum.'); + invariant(!Str\contains($enumName, '\\'), 'hackEnum must not contain a slash.'); + $enum_obj = $factory->codegenEnum($enumName, 'string') + ->addMembers($members) + ->setIsAs('string'); + $this->ctx->getFile()->addEnum($enum_obj); + } else { + $properties[] = $enum; + } + } else { + invariant(!$generateHackEnum, 'enum is required when generating hack enum'); } $coerce = $this->typed_schema['coerce'] ?? $this->ctx->getCoerceDefault(); @@ -99,8 +120,9 @@ protected function getCheckMethod(): CodegenMethod { ->addAssignment('$typed', '$sanitize_string($typed)', HackBuilderValues::literal()) ->ensureEmptyLine(); } - - $this->addEnumConstraintCheck($hb); + if (!($this->typed_schema['generateHackEnum'] ?? false)) { + $this->addEnumConstraintCheck($hb); + } $max_length = $this->typed_schema['maxLength'] ?? null; $min_length = $this->typed_schema['minLength'] ?? null; @@ -146,6 +168,9 @@ protected function getCheckMethod(): CodegenMethod { <<__Override>> public function getType(): string { if (Shapes::keyExists($this->typed_schema, 'hackEnum')) { + if ($this->typed_schema['generateHackEnum'] ?? false) { + return $this->typed_schema['hackEnum']; + } return Str\format('\%s', $this->typed_schema['hackEnum']); } diff --git a/tests/GeneratedHackEnumSchemaValidatorTest.php b/tests/GeneratedHackEnumSchemaValidatorTest.php new file mode 100644 index 0000000..64f686b --- /dev/null +++ b/tests/GeneratedHackEnumSchemaValidatorTest.php @@ -0,0 +1,35 @@ +> + public static async function beforeFirstTestAsync(): Awaitable { + $ret = self::getBuilder('generated-hack-enum-schema.json', 'GeneratedHackEnumSchemaValidator'); + $ret['codegen']->build(); + require_once($ret['path']); + } + public function testStringEnum(): void { + $cases = vec[ + shape( + 'input' => dict['enum_string' => 'one'], + 'output' => dict['enum_string' => 'one'], + 'valid' => true, + ), + shape( + 'input' => dict['enum_string' => 'four'], + 'valid' => false, + ), + shape( + 'input' => dict['enum_string' => 1], + 'valid' => false, + ), + ]; + + $this->expectCases($cases, $input ==> new GeneratedHackEnumSchemaValidator($input)); + } +} diff --git a/tests/examples/codegen/GeneratedHackEnumSchemaValidator.php b/tests/examples/codegen/GeneratedHackEnumSchemaValidator.php new file mode 100644 index 0000000..e33d603 --- /dev/null +++ b/tests/examples/codegen/GeneratedHackEnumSchemaValidator.php @@ -0,0 +1,85 @@ +> + */ +namespace Slack\Hack\JsonSchema\Tests\Generated; +use namespace Slack\Hack\JsonSchema; +use namespace Slack\Hack\JsonSchema\Constraints; + +type TGeneratedHackEnumSchemaValidator = shape( + ?'enum_string' => myCoolTestEnum, + ... +); + + +enum myCoolTestEnum : string as string { + ONE = 'one'; + TWO = 'two'; + THREE = 'three'; +} + +final class GeneratedHackEnumSchemaValidatorPropertiesEnumString { + + private static bool $coerce = false; + + public static function check(mixed $input, string $pointer): myCoolTestEnum { + $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); + + $typed = Constraints\HackEnumConstraint::check( + $typed, + myCoolTestEnum::class, + $pointer, + ); + return $typed; + } +} + +final class GeneratedHackEnumSchemaValidator + extends JsonSchema\BaseValidator { + + private static bool $coerce = false; + + public static function check( + mixed $input, + string $pointer, + ): TGeneratedHackEnumSchemaValidator { + $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); + + $errors = vec[]; + $output = shape(); + + /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ + foreach ($typed as $key => $value) { + /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ + $output[$key] = $value; + } + + if (\HH\Lib\C\contains_key($typed, 'enum_string')) { + try { + $output['enum_string'] = GeneratedHackEnumSchemaValidatorPropertiesEnumString::check( + $typed['enum_string'], + JsonSchema\get_pointer($pointer, 'enum_string'), + ); + } catch (JsonSchema\InvalidFieldException $e) { + $errors = \HH\Lib\Vec\concat($errors, $e->errors); + } + } + + if (\HH\Lib\C\count($errors)) { + throw new JsonSchema\InvalidFieldException($pointer, $errors); + } + + /* HH_IGNORE_ERROR[4163] */ + return $output; + } + + <<__Override>> + protected function process(): TGeneratedHackEnumSchemaValidator { + return self::check($this->input, $this->pointer); + } +} diff --git a/tests/examples/generated-hack-enum-schema.json b/tests/examples/generated-hack-enum-schema.json new file mode 100644 index 0000000..284da07 --- /dev/null +++ b/tests/examples/generated-hack-enum-schema.json @@ -0,0 +1,11 @@ +{ + "type": "object", + "properties": { + "enum_string": { + "type": "string", + "enum": ["one", "two", "three"], + "generateHackEnum": true, + "hackEnum": "myCoolTestEnum" + } + } +}