Skip to content

Commit 6f54092

Browse files
committed
Support easy coercion to Hack enums
1 parent 778b43d commit 6f54092

21 files changed

+481
-25
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ By defining the `source_root` and `output_root` we can generate unique validator
8282
## Developing
8383

8484
### Installing Dependencies
85-
We use composer to install our dependencies. Running the following will install composer (if it doesn't exist already) and install the dependencies:
85+
We handle all dependencies through Docker. It's as simple as:
8686

8787
```console
88-
make composer-install
88+
make build && make install
8989
```
9090

9191
### Running Tests

src/Codegen/Constraints/BaseBuilder.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Slack\Hack\JsonSchema\Codegen;
44

5+
use namespace HH\Lib\{C, Str};
56
use type Facebook\HackCodegen\{CodegenClass, CodegenMethod, CodegenProperty, HackBuilder, HackBuilderValues};
67

78
<<__ConsistentConstruct>>
@@ -92,6 +93,64 @@ protected function addEnumConstraintCheck(HackBuilder $hb): void {
9293
}
9394
}
9495

96+
protected function addHackEnumConstraintCheck(HackBuilder $hb): void {
97+
$schema = type_assert_type($this->typed_schema, TSchema::class);
98+
if (Shapes::keyExists($schema, 'hackEnum')) {
99+
try {
100+
$rc = new \ReflectionClass($schema['hackEnum']);
101+
} catch (\ReflectionException $e) {
102+
throw new \Exception(Str\format("Hack enum '%s' does not exist", $schema['hackEnum']));
103+
}
104+
105+
invariant($rc->isEnum(), "'%s' is not an enum", $schema['hackEnum']);
106+
107+
$schema_type = $schema['type'] ?? null;
108+
$hack_enum_values = keyset[];
109+
foreach ($rc->getConstants() as $hack_enum_value) {
110+
if ($schema_type === TSchemaType::INTEGER_T) {
111+
$hack_enum_value = $hack_enum_value ?as int;
112+
} else {
113+
$hack_enum_value = $hack_enum_value ?as string;
114+
}
115+
invariant(
116+
$hack_enum_value is nonnull,
117+
"'%s' must contain only values of type %s",
118+
$rc->getName(),
119+
$schema_type === TSchemaType::INTEGER_T ? 'int' : 'string'
120+
);
121+
$hack_enum_values[] = $hack_enum_value;
122+
}
123+
124+
if (Shapes::keyExists($schema, 'enum')) {
125+
// If both `enum` and `hackEnum` are specified, assert that `enum` is a subset of
126+
// `hackEnum` values.
127+
foreach ($schema['enum'] as $enum_value) {
128+
invariant(
129+
$enum_value is string,
130+
"Enum value '%s' is not a valid value for '%s'",
131+
\print_r($enum_value, true),
132+
$rc->getName()
133+
);
134+
invariant(
135+
C\contains_key($hack_enum_values, $enum_value),
136+
"Enum value '%s' is unexpectedly not present in '%s'",
137+
\print_r($enum_value, true),
138+
$rc->getName()
139+
);
140+
}
141+
}
142+
143+
$hb->addMultilineCall(
144+
'$typed = Constraints\HackEnumConstraint::check',
145+
vec[
146+
'$typed',
147+
Str\format('\%s::class', $rc->getName()),
148+
'$pointer'
149+
]
150+
);
151+
}
152+
}
153+
95154
public function addBuilderClass(CodegenClass $class): void {
96155
if ($this->class) {
97156
return;

src/Codegen/Constraints/NumberBuilder.php

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace Slack\Hack\JsonSchema\Codegen;
44

5+
use namespace HH\Lib\Str;
56
use type Facebook\HackCodegen\{CodegenMethod, HackBuilderValues};
67

78
type TNumberSchema = shape(
@@ -10,6 +11,7 @@
1011
?'minimum' => int,
1112
?'coerce' => bool,
1213
?'multipleOf' => num,
14+
?'hackEnum' => string,
1315
...
1416
);
1517

@@ -63,12 +65,11 @@ public function build(): this {
6365
protected function getCheckMethod(): CodegenMethod {
6466
$hb = $this->getHackBuilder();
6567

68+
$return_type = $this->getType();
6669
if ($this->typed_schema['type'] === TSchemaType::INTEGER_T) {
6770
$type_constraint = 'Constraints\IntegerConstraint';
68-
$return_type = 'int';
6971
} else {
7072
$type_constraint = 'Constraints\NumberConstraint';
71-
$return_type = 'num';
7273
}
7374

7475
$hb
@@ -96,6 +97,10 @@ protected function getCheckMethod(): CodegenMethod {
9697
);
9798
}
9899

100+
if ($this->typed_schema['type'] === TSchemaType::INTEGER_T) {
101+
$this->addHackEnumConstraintCheck($hb);
102+
}
103+
99104
$hb->addReturn('$typed', HackBuilderValues::literal());
100105

101106
return $this->codegenCheckMethod()
@@ -107,6 +112,9 @@ protected function getCheckMethod(): CodegenMethod {
107112
<<__Override>>
108113
public function getType(): string {
109114
if ($this->typed_schema['type'] === TSchemaType::INTEGER_T) {
115+
if (Shapes::keyExists($this->typed_schema, 'hackEnum')) {
116+
return Str\format('\%s', $this->typed_schema['hackEnum']);
117+
}
110118
return 'int';
111119
}
112120
return 'num';

src/Codegen/Constraints/SchemaBuilder.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ enum TSchemaType: string {
2424
?'$ref' => string,
2525
?'default' => mixed,
2626
?'enum' => vec<mixed>,
27+
?'hackEnum' => string,
2728
...
2829
);
2930

src/Codegen/Constraints/StringBuilder.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
namespace Slack\Hack\JsonSchema\Codegen;
44

55
use function Slack\Hack\JsonSchema\get_function_name_from_function;
6-
6+
use namespace HH\Lib\Str;
77
use type Facebook\HackCodegen\{CodegenMethod, HackBuilderValues};
88

99
type TStringSchema = shape(
1010
'type' => TSchemaType,
1111
?'maxLength' => int,
1212
?'minLength' => int,
1313
?'enum' => vec<string>,
14+
?'hackEnum' => string,
1415
?'pattern' => string,
1516
?'format' => string,
1617
?'sanitize' => shape(
@@ -132,16 +133,22 @@ protected function getCheckMethod(): CodegenMethod {
132133
);
133134
}
134135

136+
$this->addHackEnumConstraintCheck($hb);
137+
135138
$hb->addReturn('$typed', HackBuilderValues::literal());
136139

137140
return $this->codegenCheckMethod()
138141
->addParameters(vec['mixed $input', 'string $pointer'])
139142
->setBody($hb->getCode())
140-
->setReturnType('string');
143+
->setReturnType($this->getType());
141144
}
142145

143146
<<__Override>>
144147
public function getType(): string {
148+
if (Shapes::keyExists($this->typed_schema, 'hackEnum')) {
149+
return Str\format('\%s', $this->typed_schema['hackEnum']);
150+
}
151+
145152
return 'string';
146153
}
147154
}

src/Codegen/Utils.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ function _type_assert_get_type_structure(string $type) {
1212
return $s->getResolvedTypeStructure();
1313
}
1414

15+
function type_assert_type<T>(mixed $var, typename<T> $type): T {
16+
$ts = _type_assert_get_type_structure($type);
17+
$result = TypeAssert\matches_type_structure($ts, $var);
18+
return $result;
19+
}
20+
1521
function type_assert_shape<T>(mixed $var, string $shape): T {
1622
$ts = _type_assert_get_type_structure($shape);
1723
$result = TypeAssert\matches_type_structure($ts, $var);
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?hh // strict
2+
3+
namespace Slack\Hack\JsonSchema\Constraints;
4+
5+
use namespace HH\Lib\Str;
6+
use namespace Slack\Hack\JsonSchema;
7+
8+
/**
9+
* Assert the input is a valid value for a Hack enum.
10+
*
11+
* This is ideally *not* a replacement for the `enum` constraint, but should instead
12+
* be used alongside it: that way, generated code in different languages (i.e, not
13+
* in Hack) will still pass valid enum values. However, there are instances when an
14+
* enum simply has so many values that leveraging the `enum` constraint becomes unwieldy,
15+
* and `hackEnum` may still be preferable to manually-written validation in those cases.
16+
*/
17+
final class HackEnumConstraint {
18+
public static function check<T as arraykey>(mixed $input, \HH\enumname<T> $enum_class, string $pointer): T {
19+
$typed = $enum_class::coerce($input);
20+
if ($typed is nonnull) {
21+
return $typed;
22+
}
23+
24+
$error = shape(
25+
'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT,
26+
'constraint' => shape(
27+
'type' => JsonSchema\FieldErrorConstraint::ENUM,
28+
'expected' => keyset($enum_class::getNames()),
29+
'got' => $input,
30+
),
31+
'message' => 'must be a valid enum value',
32+
);
33+
throw new JsonSchema\InvalidFieldException($pointer, vec[$error]);
34+
}
35+
}

tests/ArraySchemaValidatorTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,16 @@ public function testUnsupportedUniqueItemsConstraint(): void {
216216
expect($validated)->toEqual(shape('unsupported_unique_items' => vec[shape('foo' => 'a'), shape('foo' => 'a')]));
217217
}
218218

219+
public function testHackEnumItems(): void {
220+
$input = vec['foo', 'bar'];
221+
222+
$validator = new ArraySchemaValidator(dict['hack_enum_items' => $input]);
223+
$validator->validate();
224+
225+
expect($validator->isValid())->toBeTrue();
226+
$validated = $validator->getValidatedInput();
227+
228+
expect($validated)->toEqual(shape('hack_enum_items' => vec[TestStringEnum::ABC, TestStringEnum::DEF]));
229+
}
230+
219231
}

tests/BaseCodegenTestCase.php

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@
55
use namespace HH\Lib\{C, Math, Str};
66
use function Facebook\FBExpect\expect;
77
use type Slack\Hack\JsonSchema\Validator;
8-
use type Slack\Hack\JsonSchema\Codegen\{Codegen, IJsonSchemaCodegenConfig};
8+
use type Slack\Hack\JsonSchema\Codegen\{Codegen, IJsonSchemaCodegenConfig, TSchema};
99
use type Facebook\HackTest\HackTest;
1010

1111
abstract class BaseCodegenTestCase extends HackTest {
1212

1313
const string CODEGEN_ROOT = __DIR__.'/examples/codegen';
1414
const string CODEGEN_NS = 'Slack\\Hack\\JsonSchema\\Tests\\Generated';
1515

16+
const type TOptions = shape(
17+
?'sanitize_string' => Codegen::TSanitizeStringConfig,
18+
?'json_schema_codegen_config' => IJsonSchemaCodegenConfig,
19+
?'refs' => Codegen::TValidatorRefsConfig,
20+
?'defaults' => Codegen::TValidatorDefaultsConfig,
21+
?'discard_additional_properties' => bool,
22+
);
23+
1624
public function assertUnchanged(string $_value, ?string $_token = null): void {
1725
self::markTestSkipped("assertUnchanged doesn't work in hacktest yet");
1826

@@ -61,17 +69,38 @@ public static function getCodeGenPath(string $file): string {
6169
public static function getBuilder(
6270
string $json_filename,
6371
string $name,
64-
shape(
65-
?'sanitize_string' => Codegen::TSanitizeStringConfig,
66-
?'json_schema_codegen_config' => IJsonSchemaCodegenConfig,
67-
?'refs' => Codegen::TValidatorRefsConfig,
68-
?'defaults' => Codegen::TValidatorDefaultsConfig,
69-
?'discard_additional_properties' => bool,
70-
) $options = shape(),
72+
this::TOptions $options = shape(),
73+
): shape(
74+
'path' => string,
75+
'codegen' => Codegen,
76+
) {
77+
$codegen_config = self::getConfig($name, $options);
78+
$codegen = Codegen::forPath(__DIR__."/examples/{$json_filename}", $codegen_config);
79+
80+
return shape(
81+
'path' => $codegen_config['validator']['file'],
82+
'codegen' => $codegen,
83+
);
84+
}
85+
86+
public static function getBuilderForSchema(
87+
TSchema $schema,
88+
string $name,
89+
this::TOptions $options = shape(),
7190
): shape(
7291
'path' => string,
7392
'codegen' => Codegen,
7493
) {
94+
$codegen_config = self::getConfig($name, $options);
95+
$codegen = Codegen::forSchema(Shapes::toDict($schema), $codegen_config, __DIR__."/examples/");
96+
97+
return shape(
98+
'path' => $codegen_config['validator']['file'],
99+
'codegen' => $codegen,
100+
);
101+
}
102+
103+
private static function getConfig(string $name, this::TOptions $options): Codegen::TCodegenConfig {
75104
$path = self::getCodeGenPath("{$name}.php");
76105
$validator_config = shape(
77106
'namespace' => self::CODEGEN_NS,
@@ -108,12 +137,7 @@ public static function getBuilder(
108137
$codegen_config['jsonSchemaCodegenConfig'] = $json_schema_codegen_config;
109138
}
110139

111-
$codegen = Codegen::forPath(__DIR__."/examples/{$json_filename}", $codegen_config);
112-
113-
return shape(
114-
'path' => $path,
115-
'codegen' => $codegen,
116-
);
140+
return $codegen_config;
117141
}
118142

119143
public static function benchmark(string $label, (function(): void) $callback): void {

tests/InvalidEnumTest.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?hh // strict
2+
3+
namespace Slack\Hack\JsonSchema\Tests;
4+
5+
use function Facebook\FBExpect\expect;
6+
7+
final class NotAnEnum {}
8+
9+
final class InvalidEnumTest extends BaseCodegenTestCase {
10+
11+
public function testNotAnEnum(): void {
12+
$ret = self::getBuilderForSchema(
13+
shape(
14+
'type' => 'string',
15+
'hackEnum' => NotAnEnum::class
16+
),
17+
'InvalidEnumValidator'
18+
);
19+
expect(() ==> $ret['codegen']->build())->toThrow(\HH\InvariantException::class);
20+
}
21+
22+
public function testEnumAndHackEnum(): void {
23+
$ret = self::getBuilderForSchema(
24+
shape(
25+
'type' => 'string',
26+
'hackEnum' => TestStringEnum::class,
27+
'enum' => vec['qux']
28+
),
29+
'InvalidEnumValidator'
30+
);
31+
expect(() ==> $ret['codegen']->build())->toThrow(\HH\InvariantException::class);
32+
}
33+
34+
public function testMismatchedEnumType(): void {
35+
$ret = self::getBuilderForSchema(
36+
shape(
37+
'type' => 'string',
38+
'hackEnum' => TestIntEnum::class,
39+
),
40+
'InvalidEnumValidator'
41+
);
42+
expect(() ==> $ret['codegen']->build())->toThrow(\HH\InvariantException::class);
43+
}
44+
}

tests/MultipleOfConstraintTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<?hh // partial
1+
<?hh // strict
22

33
namespace Slack\Hack\JsonSchema\Tests;
44

0 commit comments

Comments
 (0)