diff --git a/Makefile b/Makefile index e739b40..aaf03f4 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ SHELL := /bin/bash .PHONY: test autoload composer -HHVM_VERSION=4.80.5 -HHVM_NEXT_VERSION=4.80.5 +HHVM_VERSION=4.102.2 +HHVM_NEXT_VERSION=4.102.2 DOCKER_RUN=docker run -v $(shell pwd):/data --workdir /data --rm hhvm/hhvm:$(HHVM_VERSION) CONTAINER_NAME=hack-json-schema-hhvm diff --git a/src/Codegen/Constraints/ObjectBuilder.php b/src/Codegen/Constraints/ObjectBuilder.php index 3c78107..2766bc7 100644 --- a/src/Codegen/Constraints/ObjectBuilder.php +++ b/src/Codegen/Constraints/ObjectBuilder.php @@ -20,6 +20,8 @@ ?'additionalProperties' => mixed, ?'patternProperties' => dict, ?'coerce' => bool, + ?'minProperties' => int, + ?'maxProperties' => int, ... ); @@ -74,6 +76,34 @@ public function build(): this { ->setValue($hb->getCode(), HackBuilderValues::literal()); } + $max_properties = $this->typed_schema['maxProperties'] ?? null; + if ($max_properties is nonnull) { + if ($max_properties < 0) { + throw new \Exception('maxProperties must be a non-negative integer'); + } + + $class_properties[] = $this->codegenProperty('maxProperties') + ->setType('int') + ->setValue($max_properties, HackBuilderValues::export()); + } + + $min_properties = $this->typed_schema['minProperties'] ?? null; + if ($min_properties is nonnull) { + if ($min_properties < 0) { + throw new \Exception('minProperties must be a non-negative integer'); + } + + $class_properties[] = $this->codegenProperty('minProperties') + ->setType('int') + ->setValue($min_properties, HackBuilderValues::export()); + } + + if ($min_properties is nonnull && $max_properties is nonnull) { + if ($min_properties > $max_properties) { + throw new \Exception('maxProperties must be greater than minProperties'); + } + } + $class->addProperties($class_properties); $this->addBuilderClass($class); @@ -132,6 +162,29 @@ protected function getCheckMethodCode( $include_error_handling = true; } + $max_properties = $this->typed_schema['maxProperties'] ?? null; + $min_properties = $this->typed_schema['minProperties'] ?? null; + + if ($max_properties is nonnull || $min_properties is nonnull) { + $hb->addAssignment('$length', '\HH\Lib\C\count($typed)', HackBuilderValues::literal())->ensureEmptyLine(); + } + + if ($max_properties is nonnull) { + $hb->addMultilineCall( + 'Constraints\ObjectMaxPropertiesConstraint::check', + vec['$length', 'self::$maxProperties', '$pointer'], + ) + ->ensureEmptyLine(); + } + + if ($min_properties is nonnull) { + $hb->addMultilineCall( + 'Constraints\ObjectMinPropertiesConstraint::check', + vec['$length', 'self::$minProperties', '$pointer'], + ) + ->ensureEmptyLine(); + } + $defaults = $this->getDefaults(); if (!C\is_empty($defaults)) { $hb->ensureEmptyLine(); diff --git a/src/Constraints/ObjectMaxPropertiesConstraint.php b/src/Constraints/ObjectMaxPropertiesConstraint.php new file mode 100644 index 0000000..e52ff8e --- /dev/null +++ b/src/Constraints/ObjectMaxPropertiesConstraint.php @@ -0,0 +1,22 @@ + $max_properties) { + $error = shape( + 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, + 'constraint' => shape( + 'type' => JsonSchema\FieldErrorConstraint::MAX_PROPERTIES, + 'expected' => $max_properties, + 'got' => $num_properties, + ), + 'message' => "no more than {$max_properties} properties allowed", + ); + throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); + } + } +} diff --git a/src/Constraints/ObjectMinPropertiesConstraint.php b/src/Constraints/ObjectMinPropertiesConstraint.php new file mode 100644 index 0000000..6f87dc5 --- /dev/null +++ b/src/Constraints/ObjectMinPropertiesConstraint.php @@ -0,0 +1,22 @@ + JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, + 'constraint' => shape( + 'type' => JsonSchema\FieldErrorConstraint::MIN_PROPERTIES, + 'expected' => $min_properties, + 'got' => $num_properties, + ), + 'message' => "must have minimum {$min_properties} properties", + ); + throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); + } + } +} diff --git a/src/Exceptions.php b/src/Exceptions.php index bce771e..701bd6e 100644 --- a/src/Exceptions.php +++ b/src/Exceptions.php @@ -15,6 +15,8 @@ enum FieldErrorConstraint: string { MAX_ITEMS = 'max_items'; MAX_LENGTH = 'max_length'; MIN_LENGTH = 'min_length'; + MAX_PROPERTIES = 'max_properties'; + MIN_PROPERTIES = 'min_properties'; MAXIMUM = 'maximum'; MINIMUM = 'minimum'; MULTIPLE_OF = 'multiple_of'; diff --git a/tests/ObjectSchemaValidatorTest.php b/tests/ObjectSchemaValidatorTest.php index 2c2e8b0..5791f6d 100644 --- a/tests/ObjectSchemaValidatorTest.php +++ b/tests/ObjectSchemaValidatorTest.php @@ -7,6 +7,7 @@ use namespace Facebook\TypeAssert; use function Facebook\FBExpect\expect; +use type Slack\Hack\JsonSchema\{FieldErrorCode, FieldErrorConstraint}; use type Slack\Hack\JsonSchema\Tests\Generated\ObjectSchemaValidator; final class ObjectSchemaValidatorTest extends BaseCodegenTestCase { @@ -530,4 +531,165 @@ public function testAdditionalProperitesRef(): void { expect($validator->isValid())->toBeFalse(); } + public function testMinPropertiesWithValidLength(): void { + $validator = new ObjectSchemaValidator(dict[ + 'only_min_properties' => dict[ + 'a' => 0, + ], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeTrue(); + } + + public function testMinPropertiesWithInvalidLength(): void { + $validator = new ObjectSchemaValidator(dict[ + 'only_min_properties' => dict[], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeFalse(); + + $errors = $validator->getErrors(); + expect(C\count($errors))->toEqual(1); + + $error = C\firstx($errors); + expect($error['code'])->toEqual(FieldErrorCode::FAILED_CONSTRAINT); + expect($error['message'])->toEqual('must have minimum 1 properties'); + + $constraint = Shapes::at($error, 'constraint'); + expect($constraint['type'])->toEqual(FieldErrorConstraint::MIN_PROPERTIES); + expect($constraint['got'] ?? null)->toEqual(0); + } + + public function testMaxPropertiesWithValidLength(): void { + // maxProperties is set to 1, so having 1 value should be fine + $validator = new ObjectSchemaValidator(dict[ + 'only_max_properties' => dict[ + 'a' => 0, + ], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeTrue(); + + // maxProperties is set to 1, so having no values should also be fine + $validator = new ObjectSchemaValidator(dict[ + 'only_max_properties' => dict[], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeTrue(); + } + + public function testMaxPropertiesWithInvalidLength(): void { + $validator = new ObjectSchemaValidator(dict[ + 'only_max_properties' => dict[ + 'a' => 0, + 'b' => 1, + ], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeFalse(); + + $errors = $validator->getErrors(); + expect(C\count($errors))->toEqual(1); + + $error = C\firstx($errors); + expect($error['code'])->toEqual(FieldErrorCode::FAILED_CONSTRAINT); + expect($error['message'])->toEqual('no more than 1 properties allowed'); + + $constraint = Shapes::at($error, 'constraint'); + expect($constraint['type'])->toEqual(FieldErrorConstraint::MAX_PROPERTIES); + expect($constraint['got'] ?? null)->toEqual(2); + } + + public function testMinAndMaxPropertiesWithValidLength(): void { + $validator = new ObjectSchemaValidator(dict[ + 'min_and_max_properties' => dict[ + 'a' => 0, + ], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeTrue(); + + $validator = new ObjectSchemaValidator(dict[ + 'min_and_max_properties' => dict[ + 'a' => 0, + 'b' => 1, + ], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeTrue(); + } + + public function testMinAndMaxPropertiesWithInvalidLength(): void { + // minProperties is set to 1, violate it + $validator = new ObjectSchemaValidator(dict[ + 'min_and_max_properties' => dict[], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeFalse(); + + $errors = $validator->getErrors(); + expect(C\count($errors))->toEqual(1); + + $error = C\firstx($errors); + expect($error['code'])->toEqual(FieldErrorCode::FAILED_CONSTRAINT); + expect($error['message'])->toEqual('must have minimum 1 properties'); + + $constraint = Shapes::at($error, 'constraint'); + expect($constraint['type'])->toEqual(FieldErrorConstraint::MIN_PROPERTIES); + expect($constraint['got'] ?? null)->toEqual(0); + + // maxProperties is set to 2, violate it + $validator = new ObjectSchemaValidator(dict[ + 'min_and_max_properties' => dict[ + 'a' => 0, + 'b' => 1, + 'c' => 2, + ], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeFalse(); + + $errors = $validator->getErrors(); + expect(C\count($errors))->toEqual(1); + + $error = C\firstx($errors); + expect($error['code'])->toEqual(FieldErrorCode::FAILED_CONSTRAINT); + expect($error['message'])->toEqual('no more than 2 properties allowed'); + + $constraint = Shapes::at($error, 'constraint'); + expect($constraint['type'])->toEqual(FieldErrorConstraint::MAX_PROPERTIES); + expect($constraint['got'] ?? null)->toEqual(3); + } + + public function testInvalidMinPropertiesWithNoAdditionalProperties(): void { + $validator = new ObjectSchemaValidator(dict[ + 'invalid_min_properties_with_no_additional_properties' => dict[ + 'a' => 0, + ], + ]); + + $validator->validate(); + expect($validator->isValid())->toBeFalse(); + + $errors = $validator->getErrors(); + expect(C\count($errors))->toEqual(1); + + $error = C\firstx($errors); + expect($error['code'])->toEqual(FieldErrorCode::FAILED_CONSTRAINT); + expect($error['message'])->toEqual('invalid additional property: a'); + + $constraint = Shapes::at($error, 'constraint'); + expect($constraint['type'])->toEqual(FieldErrorConstraint::ADDITIONAL_PROPERTIES); + expect($constraint['got'] ?? null)->toEqual('a'); + } + } diff --git a/tests/examples/codegen/ObjectSchemaValidator.php b/tests/examples/codegen/ObjectSchemaValidator.php index 5fcab8c..b7237da 100644 --- a/tests/examples/codegen/ObjectSchemaValidator.php +++ b/tests/examples/codegen/ObjectSchemaValidator.php @@ -5,7 +5,7 @@ * To re-generate this file run `make test` * * - * @generated SignedSource<<4dc1f7d0a726caf2a6f79d0ee597cb2a>> + * @generated SignedSource<<7fa7d1f5ae7e84bf34ab173568728f05>> */ namespace Slack\Hack\JsonSchema\Tests\Generated; use namespace Slack\Hack\JsonSchema; @@ -89,6 +89,14 @@ type TObjectSchemaValidatorPropertiesAdditionalPropertiesRef = dict; +type TObjectSchemaValidatorPropertiesOnlyMinProperties = dict; + +type TObjectSchemaValidatorPropertiesOnlyMaxProperties = dict; + +type TObjectSchemaValidatorPropertiesMinAndMaxProperties = dict; + +type TObjectSchemaValidatorPropertiesInvalidMinPropertiesWithNoAdditionalProperties = dict; + type TObjectSchemaValidator = shape( ?'only_additional_properties' => TObjectSchemaValidatorPropertiesOnlyAdditionalProperties, ?'only_no_additional_properties' => TObjectSchemaValidatorPropertiesOnlyNoAdditionalProperties, @@ -107,6 +115,10 @@ ?'no_additional_properties' => TObjectSchemaValidatorPropertiesNoAdditionalProperties, ?'additional_properties_array' => TObjectSchemaValidatorPropertiesAdditionalPropertiesArray, ?'additional_properties_ref' => TObjectSchemaValidatorPropertiesAdditionalPropertiesRef, + ?'only_min_properties' => TObjectSchemaValidatorPropertiesOnlyMinProperties, + ?'only_max_properties' => TObjectSchemaValidatorPropertiesOnlyMaxProperties, + ?'min_and_max_properties' => TObjectSchemaValidatorPropertiesMinAndMaxProperties, + ?'invalid_min_properties_with_no_additional_properties' => TObjectSchemaValidatorPropertiesInvalidMinPropertiesWithNoAdditionalProperties, ... ); @@ -1508,6 +1520,134 @@ public static function check( } } +final class ObjectSchemaValidatorPropertiesOnlyMinProperties { + + private static bool $coerce = false; + private static int $minProperties = 1; + + public static function check( + mixed $input, + string $pointer, + ): TObjectSchemaValidatorPropertiesOnlyMinProperties { + $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); + $length = \HH\Lib\C\count($typed); + + Constraints\ObjectMinPropertiesConstraint::check( + $length, + self::$minProperties, + $pointer, + ); + + + /*HHAST_IGNORE_ERROR[UnusedVariable] Some functions generated with this statement do not use their $output, they use their $typed instead*/ + $output = dict[]; + + return $typed; + } +} + +final class ObjectSchemaValidatorPropertiesOnlyMaxProperties { + + private static bool $coerce = false; + private static int $maxProperties = 1; + + public static function check( + mixed $input, + string $pointer, + ): TObjectSchemaValidatorPropertiesOnlyMaxProperties { + $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); + $length = \HH\Lib\C\count($typed); + + Constraints\ObjectMaxPropertiesConstraint::check( + $length, + self::$maxProperties, + $pointer, + ); + + + /*HHAST_IGNORE_ERROR[UnusedVariable] Some functions generated with this statement do not use their $output, they use their $typed instead*/ + $output = dict[]; + + return $typed; + } +} + +final class ObjectSchemaValidatorPropertiesMinAndMaxProperties { + + private static bool $coerce = false; + private static int $maxProperties = 2; + private static int $minProperties = 1; + + public static function check( + mixed $input, + string $pointer, + ): TObjectSchemaValidatorPropertiesMinAndMaxProperties { + $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); + $length = \HH\Lib\C\count($typed); + + Constraints\ObjectMaxPropertiesConstraint::check( + $length, + self::$maxProperties, + $pointer, + ); + + Constraints\ObjectMinPropertiesConstraint::check( + $length, + self::$minProperties, + $pointer, + ); + + + /*HHAST_IGNORE_ERROR[UnusedVariable] Some functions generated with this statement do not use their $output, they use their $typed instead*/ + $output = dict[]; + + return $typed; + } +} + +final class ObjectSchemaValidatorPropertiesInvalidMinPropertiesWithNoAdditionalProperties { + + private static bool $coerce = false; + private static int $minProperties = 1; + + public static function check( + mixed $input, + string $pointer, + ): TObjectSchemaValidatorPropertiesInvalidMinPropertiesWithNoAdditionalProperties { + $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); + $length = \HH\Lib\C\count($typed); + + Constraints\ObjectMinPropertiesConstraint::check( + $length, + self::$minProperties, + $pointer, + ); + + + $errors = vec[]; + /*HHAST_IGNORE_ERROR[UnusedVariable] Some functions generated with this statement do not use their $output, they use their $typed instead*/ + $output = dict[]; + + /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ + foreach ($typed as $key => $value) { + $errors[] = shape( + 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, + 'message' => "invalid additional property: {$key}", + 'constraint' => shape( + 'type' => JsonSchema\FieldErrorConstraint::ADDITIONAL_PROPERTIES, + 'got' => $key, + ), + ); + } + + if (\HH\Lib\C\count($errors)) { + throw new JsonSchema\InvalidFieldException($pointer, $errors); + } + + return $output; + } +} + final class ObjectSchemaValidator extends JsonSchema\BaseValidator { @@ -1715,6 +1855,50 @@ public static function check( } } + if (\HH\Lib\C\contains_key($typed, 'only_min_properties')) { + try { + $output['only_min_properties'] = ObjectSchemaValidatorPropertiesOnlyMinProperties::check( + $typed['only_min_properties'], + JsonSchema\get_pointer($pointer, 'only_min_properties'), + ); + } catch (JsonSchema\InvalidFieldException $e) { + $errors = \HH\Lib\Vec\concat($errors, $e->errors); + } + } + + if (\HH\Lib\C\contains_key($typed, 'only_max_properties')) { + try { + $output['only_max_properties'] = ObjectSchemaValidatorPropertiesOnlyMaxProperties::check( + $typed['only_max_properties'], + JsonSchema\get_pointer($pointer, 'only_max_properties'), + ); + } catch (JsonSchema\InvalidFieldException $e) { + $errors = \HH\Lib\Vec\concat($errors, $e->errors); + } + } + + if (\HH\Lib\C\contains_key($typed, 'min_and_max_properties')) { + try { + $output['min_and_max_properties'] = ObjectSchemaValidatorPropertiesMinAndMaxProperties::check( + $typed['min_and_max_properties'], + JsonSchema\get_pointer($pointer, 'min_and_max_properties'), + ); + } catch (JsonSchema\InvalidFieldException $e) { + $errors = \HH\Lib\Vec\concat($errors, $e->errors); + } + } + + if (\HH\Lib\C\contains_key($typed, 'invalid_min_properties_with_no_additional_properties')) { + try { + $output['invalid_min_properties_with_no_additional_properties'] = ObjectSchemaValidatorPropertiesInvalidMinPropertiesWithNoAdditionalProperties::check( + $typed['invalid_min_properties_with_no_additional_properties'], + JsonSchema\get_pointer($pointer, 'invalid_min_properties_with_no_additional_properties'), + ); + } catch (JsonSchema\InvalidFieldException $e) { + $errors = \HH\Lib\Vec\concat($errors, $e->errors); + } + } + if (\HH\Lib\C\count($errors)) { throw new JsonSchema\InvalidFieldException($pointer, $errors); } diff --git a/tests/examples/object-schema.json b/tests/examples/object-schema.json index b99bb94..543f90e 100644 --- a/tests/examples/object-schema.json +++ b/tests/examples/object-schema.json @@ -158,6 +158,24 @@ "additionalProperties": { "$ref": "#/properties/additional_properties_array" } + }, + "only_min_properties": { + "type": "object", + "minProperties": 1 + }, + "only_max_properties": { + "type": "object", + "maxProperties": 1 + }, + "min_and_max_properties": { + "type": "object", + "minProperties": 1, + "maxProperties": 2 + }, + "invalid_min_properties_with_no_additional_properties": { + "type": "object", + "minProperties": 1, + "additionalProperties": false } } }