Skip to content

Commit

Permalink
Add support for generating hack enums from json def (#81)
Browse files Browse the repository at this point in the history
* wip

* wip

* no need to double add hack constraint

* invariant. skip reflection.

* remove leading slashes.

* tests.

* revert unneeded change.

* hackfmt

* invariant for slash

* tabs -> 2 spaces.

* typo in json

* darray -> dict
  • Loading branch information
scobb authored Jan 4, 2024
1 parent de4af98 commit 54f3ad2
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 40 deletions.
80 changes: 43 additions & 37 deletions src/Codegen/Constraints/BaseBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
);
Expand Down
31 changes: 28 additions & 3 deletions src/Codegen/Constraints/StringBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
?'minLength' => int,
?'enum' => vec<string>,
?'hackEnum' => string,
?'generateHackEnum' => bool,
?'pattern' => string,
?'format' => string,
?'sanitize' => shape(
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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']);
}

Expand Down
35 changes: 35 additions & 0 deletions tests/GeneratedHackEnumSchemaValidatorTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?hh // strict

namespace Slack\Hack\JsonSchema\Tests;


use type Slack\Hack\JsonSchema\Tests\Generated\GeneratedHackEnumSchemaValidator;

final class GeneratedHackEnumSchemaValidatorTest extends BaseCodegenTestCase {

<<__Override>>
public static async function beforeFirstTestAsync(): Awaitable<void> {
$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));
}
}
85 changes: 85 additions & 0 deletions tests/examples/codegen/GeneratedHackEnumSchemaValidator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?hh // strict
/**
* This file is generated. Do not modify it manually!
*
* To re-generate this file run `make test`
*
*
* @generated SignedSource<<ff65e010c6ed29ad1f95451eac4f8a17>>
*/
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<TGeneratedHackEnumSchemaValidator> {

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);
}
}
11 changes: 11 additions & 0 deletions tests/examples/generated-hack-enum-schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"type": "object",
"properties": {
"enum_string": {
"type": "string",
"enum": ["one", "two", "three"],
"generateHackEnum": true,
"hackEnum": "myCoolTestEnum"
}
}
}

0 comments on commit 54f3ad2

Please sign in to comment.