├── tests ├── examples │ ├── empty-schema.json │ ├── top-level-ref.json │ ├── anyof-schema-3.json │ ├── anyof-schema-1.json │ ├── anyof-schema-2.json │ ├── nested │ │ └── nested-definitions.json │ ├── boolean-schema.json │ ├── anyof-schema-strings.json │ ├── generated-hack-enum-schema.json │ ├── anyof-schema-nullable-vec.json │ ├── enum-schema.json │ ├── multiple-of.json │ ├── anyof-schema-nullable-arraykey.json │ ├── anyof-schema-nullable-strings.json │ ├── anyof-schema-4.json │ ├── anyof-schema-nested-nullable-anyof.json │ ├── anyof-schema-shapes-disjoint.json │ ├── numerical-schema.json │ ├── anyof-schema-ref-shapes.json │ ├── geo-schema.json │ ├── defaults-schema.json │ ├── anyof-schema-shapes-disabled.json │ ├── anyof-schema-vecs.json │ ├── codegen │ │ ├── TopLevelRefValidator.php │ │ ├── EmptySchemaValidator.php │ │ ├── ExternalExamplesRefSchema.php │ │ ├── ExamplesStringSchemaDefinitionsCoerce.php │ │ ├── ExamplesStringSchemaDefinitionsSimple.php │ │ ├── ExternalExamplesRefSchemaDefinitionsString.php │ │ ├── ExternalExamplesExternalDefinitionsNickname.php │ │ ├── ExamplesNestedNestedDefinitionsDefinitionsString.php │ │ ├── ExamplesRefSchemaDefinitionsLocalPropertyReference.php │ │ ├── ExamplesPersonSchemaPropertiesAge.php │ │ ├── ExamplesStringSchemaDefinitionsMaxLength.php │ │ ├── ExamplesStringSchemaDefinitionsHackEnum.php │ │ ├── ExternalExamplesFriendsSchema.php │ │ ├── AnyOfValidator3.php │ │ ├── AnyOfValidator1.php │ │ ├── ExamplesRefSchemaDefinitionsLocalObjectReference.php │ │ ├── AnyOfValidatorStrings.php │ │ ├── AnyOfValidator2.php │ │ ├── IgnoreRefsValidator.php │ │ ├── GeneratedHackEnumSchemaValidator.php │ │ ├── AnyOfValidatorNullableArraykey.php │ │ ├── AnyOfValidatorNullableStrings.php │ │ ├── BooleanSchemaValidator.php │ │ ├── AnyOfValidatorNullableVec.php │ │ ├── ExternalExamplesRefSchemaDefinitionsObject.php │ │ ├── EnumSchemaValidator.php │ │ ├── ExternalExamplesFriendsSchemaDefinitionsDevicesPhone.php │ │ ├── MultipleOfValidator.php │ │ ├── AnyOfValidatorNestedNullableAnyOf.php │ │ └── GeoSchemaValidator.php │ ├── anyof-schema-open-shapes.json │ ├── allof-schema-1.json │ ├── ignore-refs.json │ ├── anyof-schema-shapes.json │ ├── anyof-schema-open-and-closed-shapes.json │ ├── anyof-schema-required-shape-properties.json │ ├── string-schema.json │ ├── anyof-schema-nested-shapes.json │ ├── anyof-schema-many-shapes.json │ ├── discard-additional-properties-schema.json │ ├── anyof-schema-nullable-nested-shapes.json │ ├── address-schema-remote.json │ ├── address-schema.json │ ├── allof-schema-2.json │ ├── anyof-nested-anyof.json │ ├── friends-schema.json │ ├── array-schema.json │ ├── ref-schema.json │ ├── person-schema.json │ └── untyped-schema.json ├── external_examples │ ├── simple-object-ref.json │ ├── loop-schema5.json │ ├── loop-schema6.json │ ├── loop-schema2.json │ ├── loop-schema3.json │ ├── loop-schema4.json │ ├── loop-schema.json │ ├── ref-schema.json │ ├── simple-object.json │ ├── external_definitions.json │ └── friends-schema.json ├── IgnoreRefsValidatorTest.php ├── GeneratedHackEnumSchemaValidatorTest.php ├── CodegenForSchemaTest.php ├── CircularReferenceTest.php ├── EmptySchemaValidatorTest.php ├── InvalidEnumTest.php ├── TopLevelRefValidatorTest.hack ├── CodegenForPathsTest.php ├── UtilsTest.php ├── GeoSchemaValidatorTest.php ├── EnumSchemaValidatorTest.php ├── CustomCodegenConfigTest.php ├── BooleanSchemaValidatorTest.php ├── DefaultsSchemaValidatorTest.php ├── DiscardAddititionalPropertiesValidatorTest.php ├── MultipleOfConstraintTest.php ├── RefSchemaValidatorTest.php └── NumericalSchemaValidatorTest.php ├── .gitignore ├── hh_autoload.json ├── CODEOWNERS ├── Dockerfile ├── src ├── Sentinel.hack ├── Codegen │ ├── Constraints │ │ ├── IBuilder.php │ │ ├── Factory.php │ │ ├── NullBuilder.php │ │ ├── BooleanBuilder.php │ │ └── Context.php │ ├── IJsonSchemaCodegenConfig.php │ ├── Typing │ │ ├── ConcreteTypeName.hack │ │ ├── TypeAlias.hack │ │ ├── OptionalType.hack │ │ ├── ConcreteType.hack │ │ ├── Type.hack │ │ └── DAG.hack │ ├── JsonSchemaCodegenConfig.php │ ├── RefCache.php │ └── Utils.php ├── Constraints │ ├── NullConstraint.php │ ├── EnumConstraint.php │ ├── ArrayMaxItemsConstraint.php │ ├── ArrayMinItemsConstraint.php │ ├── StringMaxLengthConstraint.php │ ├── StringMinLengthConstraint.php │ ├── NumberMaximumConstraint.php │ ├── ObjectRequiredConstraint.php │ ├── NumberMinimumConstraint.php │ ├── StringPatternConstraint.php │ ├── ObjectMaxPropertiesConstraint.php │ ├── ObjectMinPropertiesConstraint.php │ ├── ArrayUniqueItemsConstraint.php │ ├── StringFormatConstraint.php │ ├── BooleanConstraint.php │ ├── NumberConstraint.php │ ├── IntegerConstraint.php │ ├── StringConstraint.php │ ├── HackEnumConstraint.php │ ├── ArrayConstraint.php │ ├── NumberMultipleOfConstraint.php │ └── ObjectConstraint.php ├── Utils.php ├── BaseValidator.php └── Exceptions.php ├── hhast-lint.json ├── bin └── hackfmt ├── composer.json ├── .github └── workflows │ ├── CI.yml │ └── hhvm.gpg.key ├── Makefile ├── .hhconfig ├── LICENSE ├── README.md └── CONTRIBUTING.md /tests/examples/empty-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | } 3 | -------------------------------------------------------------------------------- /tests/examples/top-level-ref.json: -------------------------------------------------------------------------------- 1 | { 2 | "$ref": "./numerical-schema.json#" 3 | } -------------------------------------------------------------------------------- /tests/external_examples/simple-object-ref.json: -------------------------------------------------------------------------------- 1 | { 2 | "$ref": "./simple-object.json#" 3 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .vscode 3 | .notes 4 | composer.lock 5 | composer.phar 6 | .DS_Store 7 | **/*.hhast.parser-cache 8 | .var/ -------------------------------------------------------------------------------- /tests/examples/anyof-schema-3.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { "type": "null" }, 4 | { "type": "null" } 5 | ] 6 | } -------------------------------------------------------------------------------- /tests/examples/anyof-schema-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { "type": "string" }, 4 | { "type": "null" } 5 | ] 6 | } -------------------------------------------------------------------------------- /tests/examples/anyof-schema-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { "type": "string" }, 4 | { "type": "number" } 5 | ] 6 | } -------------------------------------------------------------------------------- /tests/examples/nested/nested-definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "definitions": { 3 | "string": { 4 | "type": "string" 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /hh_autoload.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": ["src/"], 3 | "devRoots": ["tests/"], 4 | "devFailureHandler": "Facebook\\AutoloadMap\\HHClientFallbackHandler" 5 | } 6 | -------------------------------------------------------------------------------- /tests/external_examples/loop-schema5.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "trying to create a loop", 4 | "$ref": "loop-schema4.json#" 5 | } -------------------------------------------------------------------------------- /tests/external_examples/loop-schema6.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "trying to create a loop", 4 | "$ref": "loop-schema5.json#" 5 | } -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Comment line immediately above ownership line is reserved for related other information. Please be careful while editing. 2 | #ECCN:Open Source 3 | #GUSINFO:Open Source,Open Source Workflow 4 | -------------------------------------------------------------------------------- /tests/examples/boolean-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "simple": { 5 | "type": "boolean" 6 | }, 7 | "coerce": { 8 | "type": "boolean", 9 | "coerce": true 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tests/external_examples/loop-schema2.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "a second loop - this time an object", 4 | "type": "array", 5 | "items": { 6 | "$ref": "loop-schema.json#" 7 | } 8 | } -------------------------------------------------------------------------------- /tests/external_examples/loop-schema3.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "trying to create a loop", 4 | "type": "object", 5 | "properties": { 6 | "loop": {"$ref": "loop-schema4.json#"} 7 | } 8 | } -------------------------------------------------------------------------------- /tests/external_examples/loop-schema4.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "trying to create a loop", 4 | "type": "object", 5 | "properties": { 6 | "loop": {"$ref": "loop-schema3.json#"} 7 | } 8 | } -------------------------------------------------------------------------------- /tests/external_examples/loop-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "trying to create a loop", 4 | "type": "object", 5 | "properties": { 6 | "loop": {"$ref": "external_definitions.json#/loop"} 7 | } 8 | } -------------------------------------------------------------------------------- /tests/examples/anyof-schema-strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "$ref": "string-schema.json#/definitions/max_length" 5 | }, 6 | { 7 | "type": "string", 8 | "minLength": 12 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tests/examples/generated-hack-enum-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "enum_string": { 5 | "type": "string", 6 | "enum": ["one", "two", "three"], 7 | "generateHackEnum": true, 8 | "hackEnum": "myCoolTestEnum" 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-nullable-vec.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "null" 5 | }, 6 | { 7 | "type": "array", 8 | "items": { 9 | "type": "string" 10 | } 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM hhvm/hhvm:4.153.0 2 | 3 | RUN apt update -y 4 | RUN DEBIAN_FRONTEND=noninteractive apt install -y php-cli zip unzip openssh-client 5 | 6 | COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer 7 | ENV HHVM_VERSION=4.153.0 8 | 9 | WORKDIR /app 10 | COPY . /app 11 | -------------------------------------------------------------------------------- /tests/examples/enum-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "enum_string": { 5 | "type": "string", 6 | "enum": ["one", "two", "three"] 7 | }, 8 | "enum_number": { 9 | "type": "number", 10 | "enum": [1, 2, 3] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/examples/multiple-of.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "a_multiple_of_five_int": { 5 | "type": "integer", 6 | "multipleOf": 5 7 | }, 8 | "a_multiple_of_1_point_one_repeating_number": { 9 | "type": "number", 10 | "multipleOf": 1.1111111111111111 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /tests/examples/anyof-schema-nullable-arraykey.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "$ref": "string-schema.json#/definitions/max_length" 5 | }, 6 | { 7 | "type": "integer", 8 | "maximum": 10 9 | }, 10 | { 11 | "type": "null" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-nullable-strings.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "$ref": "string-schema.json#/definitions/max_length" 5 | }, 6 | { 7 | "type": "string", 8 | "minLength": 12 9 | }, 10 | { 11 | "type": "null" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/Sentinel.hack: -------------------------------------------------------------------------------- 1 | namespace Slack\Hack\JsonSchema; 2 | 3 | /** 4 | * Represents an unset value. 5 | */ 6 | final class Sentinel { 7 | 8 | /** 9 | * The singleton instance of the sentinal value. 10 | */ 11 | <<__Memoize>> 12 | public static function get(): this { 13 | return new self(); 14 | } 15 | 16 | private function __construct () {} 17 | } 18 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-4.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { "type": "null" }, 4 | { 5 | "type": "object", 6 | "properties": { 7 | "post-office-box": { "type": "string" }, 8 | "extended-address": { "type": "string" }, 9 | "street-address": { "type": "string" } 10 | } 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /tests/examples/anyof-schema-nested-nullable-anyof.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "integer" 5 | }, 6 | { 7 | "anyOf": [ 8 | { 9 | "type": "null" 10 | }, 11 | { 12 | "type": "number" 13 | } 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/Codegen/Constraints/IBuilder.php: -------------------------------------------------------------------------------- 1 | JsonSchema\FieldErrorCode::INVALID_TYPE, 14 | 'message' => 'must provide a null pointer', 15 | ); 16 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/external_examples/simple-object.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "additionalProperties": false, 4 | "properties": { 5 | "simple_object_prop_1": { 6 | "type": "integer" 7 | }, 8 | "simple_object_prop_2": { 9 | "type": "object", 10 | "additionalProperties": false, 11 | "properties": { 12 | "prop_1": { 13 | "type": "integer" 14 | } 15 | }, 16 | "required": ["prop_1"] 17 | } 18 | }, 19 | "required": ["simple_object_prop_1"] 20 | } -------------------------------------------------------------------------------- /tests/examples/anyof-schema-shapes-disabled.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "object", 5 | "additionalProperties": false, 6 | "required": ["bar"], 7 | "properties": { 8 | "bar": { 9 | "type": "integer" 10 | } 11 | } 12 | }, 13 | { 14 | "type": "object", 15 | "additionalProperties": false, 16 | "properties": { 17 | "bar": { 18 | "type": "string" 19 | } 20 | } 21 | } 22 | ], 23 | "disableShapeUnification": true 24 | } 25 | -------------------------------------------------------------------------------- /src/Codegen/Constraints/Factory.php: -------------------------------------------------------------------------------- 1 | ctx->getJsonSchemaCodegenConfig(); 12 | return $config->getClassNameFormatFunction()(...$parts); 13 | } 14 | 15 | protected function generateTypeName(string $input): string { 16 | $config = $this->ctx->getJsonSchemaCodegenConfig(); 17 | $processed = $config->getTypeNameFormatFunction()($input); 18 | return Str\format('%s%s%s', $config->getTypeNamePrefix(), $processed, $config->getTypeNameSuffix()); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-vecs.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "array", 5 | "items": { 6 | "type": "string" 7 | } 8 | }, 9 | { 10 | "type": "array", 11 | "items": { 12 | "type": "integer" 13 | } 14 | }, 15 | { 16 | "type": "array", 17 | "items": { 18 | "anyOf": [ 19 | { 20 | "type": "null" 21 | }, 22 | { 23 | "type": "integer" 24 | } 25 | ] 26 | } 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /tests/examples/codegen/TopLevelRefValidator.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | 13 | type TTopLevelRefValidator = TExamplesNumericalSchema; 14 | 15 | final class TopLevelRefValidator 16 | extends JsonSchema\BaseValidator { 17 | 18 | <<__Override>> 19 | protected function process(): TExamplesNumericalSchema { 20 | return ExamplesNumericalSchema::check($this->input, $this->pointer); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Codegen/JsonSchemaCodegenConfig.php: -------------------------------------------------------------------------------- 1 | ; 16 | } 17 | 18 | public function getTypeNameFormatFunction(): (function(string...): string) { 19 | return format<>; 20 | } 21 | 22 | public function getFileNameFormatFunction(): (function(string...): string) { 23 | return format<>; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-open-shapes.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "object", 5 | "required": ["bar"], 6 | "properties": { 7 | "foo": { 8 | "type": "integer" 9 | }, 10 | "bar": { 11 | "type": "array", 12 | "items": { 13 | "type": "integer" 14 | } 15 | } 16 | } 17 | }, 18 | { 19 | "type": "object", 20 | "properties": { 21 | "foo": { 22 | "type": "string" 23 | } 24 | } 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /tests/examples/allof-schema-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [ 3 | { 4 | "type": "object", 5 | "additionalProperties": false, 6 | "properties": { 7 | "foo": { 8 | "type": "integer", 9 | "minimum": 0 10 | } 11 | }, 12 | "required": ["foo"] 13 | }, 14 | { 15 | "type": "object", 16 | "additionalProperties": false, 17 | "properties": { 18 | "bar": { 19 | "type": "integer", 20 | "maximum": 10 21 | } 22 | }, 23 | "required": ["bar"], 24 | "coerce": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /hhast-lint.json: -------------------------------------------------------------------------------- 1 | { 2 | "roots": ["src", "tests"], 3 | "builtinLinters": "all", 4 | "extraLinters": ["Facebook\\HHAST\\Linters\\PreferLambdasLinter"], 5 | "disabledLinters": [ 6 | "Facebook\\HHAST\\Linters\\DontHaveTwoEmptyLinesInARowLinter", 7 | "Facebook\\HHAST\\Linters\\NoNewlineAtStartOfControlFlowBlockLinter", 8 | "Facebook\\HHAST\\Linters\\LicenseHeaderLinter", 9 | "Facebook\\HHAST\\Linters\\NoStringInterpolationLinter", 10 | "Facebook\\HHAST\\Linters\\NoPHPEqualityLinter", 11 | "Facebook\\HHAST\\Linters\\AsyncFunctionAndMethodLinter", 12 | "Facebook\\HHAST\\Linters\\StrictModeOnlyLinter" 13 | ], 14 | "overrides": [ 15 | { 16 | "patterns": ["*codegen*"], 17 | "disableAllAutoFixes": true 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tests/examples/ignore-refs.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "randomprop": { 5 | "$ref": "#/definitions/objectRef", 6 | "hackIgnoreRef": true 7 | } 8 | }, 9 | "definitions": { 10 | "otherRef": { 11 | "$ref": "#/definitions/anotherRef" 12 | }, 13 | "anotherRef": { 14 | "$ref": "#/definitions/otherRef" 15 | }, 16 | "objectRef": { 17 | "type": "object", 18 | "properties": { 19 | "thing": { 20 | "$ref": "#/definitions/objectRef2" 21 | } 22 | 23 | } 24 | }, 25 | "objectRef2": { 26 | "$ref": "#/definitions/objectRef" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Constraints/EnumConstraint.php: -------------------------------------------------------------------------------- 1 | $enum, string $pointer): void { 10 | if (!C\contains($enum, $input)) { 11 | $error = shape( 12 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 13 | 'constraint' => shape( 14 | 'type' => JsonSchema\FieldErrorConstraint::ENUM, 15 | 'expected' => $enum, 16 | 'got' => $input, 17 | ), 18 | 'message' => 'must be a valid enum value', 19 | ); 20 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Constraints/ArrayMaxItemsConstraint.php: -------------------------------------------------------------------------------- 1 | $max_items) { 10 | $error = shape( 11 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 12 | 'constraint' => shape( 13 | 'type' => JsonSchema\FieldErrorConstraint::MAX_ITEMS, 14 | 'expected' => $max_items, 15 | 'got' => $num_items, 16 | ), 17 | 'message' => "no more than {$max_items} items allowed", 18 | ); 19 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Constraints/ArrayMinItemsConstraint.php: -------------------------------------------------------------------------------- 1 | $num_items) { 10 | $error = shape( 11 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 12 | 'constraint' => shape( 13 | 'type' => JsonSchema\FieldErrorConstraint::MIN_ITEMS, 14 | 'expected' => $min_items, 15 | 'got' => $num_items, 16 | ), 17 | 'message' => "must provide at least {$min_items} items", 18 | ); 19 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Constraints/StringMaxLengthConstraint.php: -------------------------------------------------------------------------------- 1 | $maximum) { 10 | $error = shape( 11 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 12 | 'constraint' => shape( 13 | 'type' => JsonSchema\FieldErrorConstraint::MAX_LENGTH, 14 | 'expected' => $maximum, 15 | 'got' => $length, 16 | ), 17 | 'message' => 'must be less than '.($maximum + 1).' characters', 18 | ); 19 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Constraints/StringMinLengthConstraint.php: -------------------------------------------------------------------------------- 1 | $length) { 10 | $error = shape( 11 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 12 | 'constraint' => shape( 13 | 'type' => JsonSchema\FieldErrorConstraint::MIN_LENGTH, 14 | 'expected' => $minimum, 15 | 'got' => $length, 16 | ), 17 | 'message' => 'must be more than '.($minimum - 1).' characters', 18 | ); 19 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Constraints/NumberMaximumConstraint.php: -------------------------------------------------------------------------------- 1 | $maximum) { 11 | $error = shape( 12 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 13 | 'constraint' => shape( 14 | 'type' => JsonSchema\FieldErrorConstraint::MAXIMUM, 15 | 'expected' => $maximum, 16 | 'got' => $input, 17 | ), 18 | 'message' => Str\format('must be less than %s', (string)$maximum), 19 | ); 20 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Constraints/ObjectRequiredConstraint.php: -------------------------------------------------------------------------------- 1 | $input, keyset $required, string $pointer): void { 10 | $errors = vec[]; 11 | foreach ($required as $name) { 12 | if (!C\contains_key($input, $name)) { 13 | $errors[] = shape( 14 | 'code' => JsonSchema\FieldErrorCode::MISSING_FIELD, 15 | 'message' => "missing required field: {$name}", 16 | 'field' => $name, 17 | ); 18 | } 19 | } 20 | 21 | if (C\count($errors)) { 22 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/IgnoreRefsValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 13 | public static async function beforeFirstTestAsync(): Awaitable { 14 | $ret = self::getBuilder('ignore-refs.json', 'IgnoreRefsValidator'); 15 | $ret['codegen']->build(); 16 | require_once($ret['path']); 17 | } 18 | 19 | public function testInvalidEmptyInput(): void { 20 | $validator = new IgnoreRefsValidator(dict['randomprop' => 'IanIzzy']); 21 | $validator->validate(); 22 | expect($validator->isValid())->toBeTrue(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-shapes.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "object", 5 | "additionalProperties": false, 6 | "required": ["bar"], 7 | "properties": { 8 | "foo": { 9 | "type": "integer" 10 | }, 11 | "bar": { 12 | "type": "array", 13 | "items": { 14 | "type": "integer" 15 | } 16 | } 17 | } 18 | }, 19 | { 20 | "type": "object", 21 | "additionalProperties": false, 22 | "properties": { 23 | "foo": { 24 | "type": "string" 25 | } 26 | } 27 | } 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /src/Constraints/NumberMinimumConstraint.php: -------------------------------------------------------------------------------- 1 | JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 13 | 'constraint' => shape( 14 | 'type' => JsonSchema\FieldErrorConstraint::MINIMUM, 15 | 'expected' => $minimum, 16 | 'got' => $input, 17 | ), 18 | 'message' => Str\format('must be greater than %s', (string)$minimum), 19 | ); 20 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 21 | 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Constraints/StringPatternConstraint.php: -------------------------------------------------------------------------------- 1 | JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 13 | 'constraint' => shape( 14 | 'type' => JsonSchema\FieldErrorConstraint::PATTERN, 15 | 'expected' => $pattern, 16 | 'got' => $input, 17 | ), 18 | 'message' => "input must match regex pattern: {$pattern}", 19 | ); 20 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /tests/examples/codegen/EmptySchemaValidator.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | 13 | type TEmptySchemaValidator = mixed; 14 | 15 | final class EmptySchemaValidator 16 | extends JsonSchema\BaseValidator { 17 | 18 | public static function check( 19 | mixed $input, 20 | string $_pointer, 21 | ): TEmptySchemaValidator { 22 | return $input; 23 | } 24 | 25 | <<__Override>> 26 | protected function process(): TEmptySchemaValidator { 27 | return self::check($this->input, $this->pointer); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /bin/hackfmt: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | file=`realpath $1` 4 | shift # remove filename so getopts ignores it 5 | 6 | # Since this wrapper performs in-place modification of a file, it can speed up 7 | # some editor workflows (e.g., Sublime) to perform the formatting over a 8 | # tempfile that is generated during a pre-save hook. Such tempfiles will cause 9 | # false negatives in the test for ignored file paths, so we provide an option 10 | # for the caller to provide an effective ignore path as a flag. 11 | ignore_path=$file 12 | while getopts "i:" opt; do 13 | case ${opt} in 14 | i ) 15 | ignore_path=$OPTARG 16 | shift 17 | ;; 18 | esac 19 | done 20 | shift $((OPTIND -1)) # cleanup getopts 21 | 22 | # run hackfmt if file doesn't match regex of ignored file paths 23 | [[ "$ignore_path" =~ ^(examples|external_examples) ]] || hackfmt --in-place $file 24 | -------------------------------------------------------------------------------- /src/Constraints/ObjectMaxPropertiesConstraint.php: -------------------------------------------------------------------------------- 1 | $max_properties) { 10 | $error = shape( 11 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 12 | 'constraint' => shape( 13 | 'type' => JsonSchema\FieldErrorConstraint::MAX_PROPERTIES, 14 | 'expected' => $max_properties, 15 | 'got' => $num_properties, 16 | ), 17 | 'message' => "no more than {$max_properties} properties allowed", 18 | ); 19 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Constraints/ObjectMinPropertiesConstraint.php: -------------------------------------------------------------------------------- 1 | JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 12 | 'constraint' => shape( 13 | 'type' => JsonSchema\FieldErrorConstraint::MIN_PROPERTIES, 14 | 'expected' => $min_properties, 15 | 'got' => $num_properties, 16 | ), 17 | 'message' => "must have minimum {$min_properties} properties", 18 | ); 19 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "slack/hack-json-schema", 3 | "description": "Hack JSON Schema generator", 4 | "type": "library", 5 | "authors": [ 6 | { 7 | "name": "Michael Hahn", 8 | "email": "mh@slack-corp.com" 9 | } 10 | ], 11 | "require": { 12 | "facebook/hack-codegen": "^4.1.0", 13 | "hhvm/hsl": "^4.0.1", 14 | "hhvm/type-assert": "^4.1.0" 15 | }, 16 | "require-dev": { 17 | "hhvm/hhast": "^4.0.5", 18 | "facebook/fbexpect": "^2.3.0", 19 | "hhvm/hacktest": "^1.3|^2.0" 20 | }, 21 | "autoload": { 22 | "classmap": [ "src/" ] 23 | }, 24 | "autoload-dev": { 25 | "classmap": [ "tests/" ] 26 | }, 27 | "config": { 28 | "allow-plugins": { 29 | "hhvm/hhvm-autoload": true 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExternalExamplesRefSchema.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | 13 | type TExternalExamplesRefSchema = mixed; 14 | 15 | final class ExternalExamplesRefSchema 16 | extends JsonSchema\BaseValidator { 17 | 18 | public static function check( 19 | mixed $input, 20 | string $_pointer, 21 | ): TExternalExamplesRefSchema { 22 | return $input; 23 | } 24 | 25 | <<__Override>> 26 | protected function process(): TExternalExamplesRefSchema { 27 | return self::check($this->input, $this->pointer); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | on: pull_request 3 | jobs: 4 | test: 5 | name: Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v2 10 | with: 11 | ref: ${{ github.event.pull_request.head.sha }} 12 | - name: Build 13 | run: | 14 | docker build -t slack/hack-json-schema . 15 | - name: Install 16 | run: | 17 | docker run -v `pwd`:/app slack/hack-json-schema composer install 18 | - name: Typecheck 19 | run: | 20 | docker run -v `pwd`:/app slack/hack-json-schema hh_client 21 | - name: Test 22 | run: | 23 | docker run -v `pwd`:/app slack/hack-json-schema ./vendor/bin/hacktest tests -------------------------------------------------------------------------------- /src/Constraints/ArrayUniqueItemsConstraint.php: -------------------------------------------------------------------------------- 1 | (vec $input, string $pointer, bool $coerce): keyset { 10 | $output = keyset($input); 11 | if (!$coerce && C\count($output) < C\count($input)) { 12 | $error = shape( 13 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 14 | 'constraint' => shape( 15 | 'type' => JsonSchema\FieldErrorConstraint::UNIQUE_ITEMS, 16 | 'got' => $input, 17 | ), 18 | 'message' => 'Input contains duplicate items', 19 | ); 20 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 21 | } 22 | return $output; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-open-and-closed-shapes.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "object", 5 | "required": ["bar"], 6 | "properties": { 7 | "foo": { 8 | "type": "integer" 9 | }, 10 | "bar": { 11 | "type": "array", 12 | "items": { 13 | "type": "integer" 14 | } 15 | } 16 | } 17 | }, 18 | { 19 | "type": "object", 20 | "additionalProperties": false, 21 | "properties": { 22 | "foo": { 23 | "type": "string" 24 | }, 25 | "baz": { 26 | "type": "boolean" 27 | } 28 | } 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /tests/external_examples/external_definitions.json: -------------------------------------------------------------------------------- 1 | { 2 | "nickname": {"type": "string"}, 3 | "loop": {"$ref": "loop-schema.json#/properties/loop"}, 4 | "person": { 5 | "type": "object", 6 | "additionalProperties": false, 7 | "properties": { 8 | "nickname": { "$ref": "#/nickname" } 9 | } 10 | }, 11 | "shape_1": { 12 | "type": "object", 13 | "properties": { 14 | "foo": { 15 | "type": "array", 16 | "items": { 17 | "type": "integer" 18 | } 19 | }, 20 | "baz": { 21 | "type": "integer" 22 | } 23 | } 24 | }, 25 | "shape_2": { 26 | "type": "object", 27 | "properties": { 28 | "foo": { 29 | "type": "array", 30 | "items": { 31 | "type": "string" 32 | } 33 | }, 34 | "baz": { 35 | "type": "string" 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build test hh_autoload format 2 | 3 | build: 4 | docker build -t slack/hack-json-schema . 5 | 6 | install: build 7 | docker run -v `pwd`:/app -it slack/hack-json-schema composer install 8 | 9 | update: build 10 | docker run -v `pwd`:/app -it slack/hack-json-schema composer update 11 | 12 | hh_autoload: 13 | docker run -v `pwd`:/app -it slack/hack-json-schema ./vendor/bin/hh-autoload 14 | 15 | test: 16 | docker run -v `pwd`:/app -it slack/hack-json-schema ./vendor/bin/hacktest tests 17 | 18 | lint: 19 | docker run -v `pwd`:/app -it slack/hack-json-schema ./vendor/bin/hhast-lint 20 | 21 | format: 22 | docker run -v `pwd`:/app -it slack/hack-json-schema find {src,tests} -type f \( -name "*.hack" -o -name "*.php" \) -exec hackfmt -i {} \; 23 | 24 | typecheck: 25 | docker run -v `pwd`:/app -it slack/hack-json-schema /bin/bash -c './vendor/bin/hh-autoload && hh_server --check .' 26 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExamplesStringSchemaDefinitionsCoerce.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExamplesStringSchemaDefinitionsCoerce 15 | extends JsonSchema\BaseValidator { 16 | 17 | private static bool $coerce = true; 18 | 19 | public static function check(mixed $input, string $pointer): string { 20 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 21 | 22 | return $typed; 23 | } 24 | 25 | <<__Override>> 26 | protected function process(): string { 27 | return self::check($this->input, $this->pointer); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExamplesStringSchemaDefinitionsSimple.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExamplesStringSchemaDefinitionsSimple 15 | extends JsonSchema\BaseValidator { 16 | 17 | private static bool $coerce = false; 18 | 19 | public static function check(mixed $input, string $pointer): string { 20 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 21 | 22 | return $typed; 23 | } 24 | 25 | <<__Override>> 26 | protected function process(): string { 27 | return self::check($this->input, $this->pointer); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExternalExamplesRefSchemaDefinitionsString.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExternalExamplesRefSchemaDefinitionsString 15 | extends JsonSchema\BaseValidator { 16 | 17 | private static bool $coerce = false; 18 | 19 | public static function check(mixed $input, string $pointer): string { 20 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 21 | 22 | return $typed; 23 | } 24 | 25 | <<__Override>> 26 | protected function process(): string { 27 | return self::check($this->input, $this->pointer); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExternalExamplesExternalDefinitionsNickname.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExternalExamplesExternalDefinitionsNickname 15 | extends JsonSchema\BaseValidator { 16 | 17 | private static bool $coerce = false; 18 | 19 | public static function check(mixed $input, string $pointer): string { 20 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 21 | 22 | return $typed; 23 | } 24 | 25 | <<__Override>> 26 | protected function process(): string { 27 | return self::check($this->input, $this->pointer); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExamplesNestedNestedDefinitionsDefinitionsString.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExamplesNestedNestedDefinitionsDefinitionsString 15 | extends JsonSchema\BaseValidator { 16 | 17 | private static bool $coerce = false; 18 | 19 | public static function check(mixed $input, string $pointer): string { 20 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 21 | 22 | return $typed; 23 | } 24 | 25 | <<__Override>> 26 | protected function process(): string { 27 | return self::check($this->input, $this->pointer); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExamplesRefSchemaDefinitionsLocalPropertyReference.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExamplesRefSchemaDefinitionsLocalPropertyReference 15 | extends JsonSchema\BaseValidator { 16 | 17 | private static bool $coerce = false; 18 | 19 | public static function check(mixed $input, string $pointer): string { 20 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 21 | 22 | return $typed; 23 | } 24 | 25 | <<__Override>> 26 | protected function process(): string { 27 | return self::check($this->input, $this->pointer); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-required-shape-properties.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "object", 5 | "required": ["foo", "bar"], 6 | "properties": { 7 | "foo": { 8 | "type": "integer" 9 | }, 10 | "bar": { 11 | "type": "array", 12 | "items": { 13 | "type": "integer" 14 | } 15 | } 16 | } 17 | }, 18 | { 19 | "type": "object", 20 | "required": ["foo"], 21 | "properties": { 22 | "foo": { 23 | "type": "string" 24 | }, 25 | "bar": { 26 | "type": "array", 27 | "items": { 28 | "type": "string" 29 | } 30 | } 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /.hhconfig: -------------------------------------------------------------------------------- 1 | hackfmt.line_width=120 2 | ignored_paths = [ "vendor/.+/tests/.+", "vendor/.+/bin/.+" ] 3 | allowed_decl_fixme_codes=1002,2001,2050,2053,2071,3012,3015,4030,4032,4035,4047,4070,4101,4341 4 | allowed_fixme_codes_strict=1002,2001,2011,2025,2049,2050,2053,2071,2105,3012,3015,4005,4006,4009,4011,4026,4027,4030,4032,4035,4047,4051,4053,4057,4060,4062,4063,4064,4070,4101,4104,4106,4107,4108,4110,4112,4117,4119,4128,4135,4138,4163,4165,4188,4193,4224,4240,4249,4250,4281,4297,4298,4318,4321,4323,4324,4337,4341,4343,4370,4371,4372,4390 5 | allowed_fixme_codes_partial=1002,2001,2011,2025,2049,2050,2053,2071,2105,3012,3015,4005,4006,4009,4011,4026,4027,4030,4032,4035,4047,4051,4053,4057,4060,4062,4063,4064,4070,4101,4104,4106,4107,4108,4110,4112,4117,4119,4128,4135,4138,4163,4165,4188,4193,4224,4240,4249,4250,4281,4297,4298,4318,4321,4323,4324,4337,4341,4343,4370,4371,4372,4390 6 | enable_xhp_class_modifier=true 7 | disable_xhp_element_mangling=true 8 | disallow_hash_comments=true 9 | enable_strict_string_concat_interp=true -------------------------------------------------------------------------------- /src/Constraints/StringFormatConstraint.php: -------------------------------------------------------------------------------- 1 | JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 17 | 'constraint' => shape( 18 | 'type' => JsonSchema\FieldErrorConstraint::FORMAT, 19 | 'expected' => $format, 20 | 'got' => $input, 21 | ), 22 | 'message' => "input must be a valid: {$format}", 23 | ); 24 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 25 | } 26 | break; 27 | default: 28 | break; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Constraints/BooleanConstraint.php: -------------------------------------------------------------------------------- 1 | JsonSchema\FieldErrorCode::INVALID_TYPE, 32 | 'message' => 'must be a boolean', 33 | ); 34 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Constraints/NumberConstraint.php: -------------------------------------------------------------------------------- 1 | JsonSchema\FieldErrorCode::INVALID_TYPE, 16 | 'message' => 'must provide a number', 17 | ); 18 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 19 | } 20 | } 21 | 22 | try { 23 | return TypeAssert\num($input); 24 | } catch (TypeAssert\IncorrectTypeException $e) { 25 | $error = shape( 26 | 'code' => JsonSchema\FieldErrorCode::INVALID_TYPE, 27 | 'message' => 'must provide a number', 28 | ); 29 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Constraints/IntegerConstraint.php: -------------------------------------------------------------------------------- 1 | JsonSchema\FieldErrorCode::INVALID_TYPE, 16 | 'message' => 'must provide a number', 17 | ); 18 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 19 | } 20 | } 21 | 22 | try { 23 | return TypeAssert\int($input); 24 | } catch (TypeAssert\IncorrectTypeException $e) { 25 | $error = shape( 26 | 'code' => JsonSchema\FieldErrorCode::INVALID_TYPE, 27 | 'message' => 'must provide an integer', 28 | ); 29 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExamplesPersonSchemaPropertiesAge.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExamplesPersonSchemaPropertiesAge 15 | extends JsonSchema\BaseValidator { 16 | 17 | private static int $minimum = 0; 18 | private static bool $coerce = false; 19 | 20 | public static function check(mixed $input, string $pointer): int { 21 | $typed = 22 | Constraints\IntegerConstraint::check($input, $pointer, self::$coerce); 23 | 24 | Constraints\NumberMinimumConstraint::check( 25 | $typed, 26 | self::$minimum, 27 | $pointer, 28 | ); 29 | return $typed; 30 | } 31 | 32 | <<__Override>> 33 | protected function process(): int { 34 | return self::check($this->input, $this->pointer); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Constraints/StringConstraint.php: -------------------------------------------------------------------------------- 1 | JsonSchema\FieldErrorCode::INVALID_TYPE, 17 | 'message' => 'must provide a string', 18 | ); 19 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 20 | } 21 | } 22 | 23 | try { 24 | return TypeAssert\string($input); 25 | } catch (TypeAssert\IncorrectTypeException $e) { 26 | $error = shape( 27 | 'code' => JsonSchema\FieldErrorCode::INVALID_TYPE, 28 | 'message' => 'must provide a string', 29 | ); 30 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Utils.php: -------------------------------------------------------------------------------- 1 | encode_path($path)) 9 | |> Str\join($$, '/'); 10 | 11 | return !Str\is_empty($current) ? "{$current}/{$encoded}" : "/{$encoded}"; 12 | } 13 | 14 | function get_function_name_from_function(mixed $function): string { 15 | $func_name = ''; 16 | $is_function = \is_callable_with_name($function, false, inout $func_name); 17 | invariant($is_function, 'You may only pass named functions to %s', __FUNCTION__); 18 | return $func_name; 19 | } 20 | 21 | /** 22 | * Encode paths within the pointer according to RFC-6901 23 | * (https://tools.ietf.org/html/rfc6901) 24 | */ 25 | function encode_path(string $path): string { 26 | return \strtr($path, dict['/' => '~1', '~' => '~0']); 27 | } 28 | 29 | function json_decode_hack(mixed $json): mixed { 30 | return \json_decode( 31 | (string)$json, 32 | true, /* default stack_depth */ 33 | 512, 34 | \JSON_FB_HACK_ARRAYS, 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /tests/examples/string-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "simple": { 5 | "type": "string" 6 | }, 7 | "coerce": { 8 | "$ref": "#/definitions/coerce" 9 | }, 10 | "sanitize_uniline": { 11 | "type": "string", 12 | "sanitize": { 13 | "multiline": false 14 | } 15 | }, 16 | "sanitize_multiline": { 17 | "type": "string", 18 | "sanitize": { 19 | "multiline": true 20 | } 21 | }, 22 | "date_format": { 23 | "type": "string", 24 | "format": "date" 25 | }, 26 | "hack_enum": { 27 | "type": "string", 28 | "hackEnum": "Slack\\Hack\\JsonSchema\\Tests\\TestStringEnum" 29 | } 30 | }, 31 | "definitions": { 32 | "simple": { 33 | "type": "string" 34 | }, 35 | "coerce": { 36 | "type": "string", 37 | "coerce": true 38 | }, 39 | "hack_enum": { 40 | "type": "string", 41 | "hackEnum": "Slack\\Hack\\JsonSchema\\Tests\\TestStringEnum" 42 | }, 43 | "max_length": { 44 | "type": "string", 45 | "maxLength": 10 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Slack Technologies, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/GeneratedHackEnumSchemaValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 11 | public static async function beforeFirstTestAsync(): Awaitable { 12 | $ret = self::getBuilder('generated-hack-enum-schema.json', 'GeneratedHackEnumSchemaValidator'); 13 | $ret['codegen']->build(); 14 | require_once($ret['path']); 15 | } 16 | public function testStringEnum(): void { 17 | $cases = vec[ 18 | shape( 19 | 'input' => dict['enum_string' => 'one'], 20 | 'output' => dict['enum_string' => 'one'], 21 | 'valid' => true, 22 | ), 23 | shape( 24 | 'input' => dict['enum_string' => 'four'], 25 | 'valid' => false, 26 | ), 27 | shape( 28 | 'input' => dict['enum_string' => 1], 29 | 'valid' => false, 30 | ), 31 | ]; 32 | 33 | $this->expectCases($cases, $input ==> new GeneratedHackEnumSchemaValidator($input)); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExamplesStringSchemaDefinitionsMaxLength.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExamplesStringSchemaDefinitionsMaxLength 15 | extends JsonSchema\BaseValidator { 16 | 17 | private static int $maxLength = 10; 18 | private static bool $coerce = false; 19 | 20 | public static function check(mixed $input, string $pointer): string { 21 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 22 | 23 | $length = \mb_strlen($typed); 24 | Constraints\StringMaxLengthConstraint::check( 25 | $length, 26 | self::$maxLength, 27 | $pointer, 28 | ); 29 | return $typed; 30 | } 31 | 32 | <<__Override>> 33 | protected function process(): string { 34 | return self::check($this->input, $this->pointer); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-nested-shapes.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "object", 5 | "required": ["foo"], 6 | "properties": { 7 | "foo": { 8 | "type": "object", 9 | "additionalProperties": false, 10 | "properties": { 11 | "baz": { 12 | "type": "integer" 13 | } 14 | } 15 | } 16 | } 17 | }, 18 | { 19 | "type": "object", 20 | "required": ["foo"], 21 | "properties": { 22 | "foo": { 23 | "type": "object", 24 | "additionalProperties": false, 25 | "properties": { 26 | "baz": { 27 | "type": "string" 28 | }, 29 | "qux": { 30 | "type": "boolean" 31 | } 32 | } 33 | } 34 | } 35 | } 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /tests/CodegenForSchemaTest.php: -------------------------------------------------------------------------------- 1 | self::CODEGEN_NS, 23 | 'file' => $path, 24 | 'class' => $name, 25 | 'refs' => shape( 26 | 'root_directory' => $root_directory, 27 | ), 28 | ); 29 | 30 | $codegen_config = shape( 31 | 'generatedFrom' => '`make test`', 32 | 'validator' => $validator_config, 33 | ); 34 | 35 | Codegen::forSchema($schema, $codegen_config, $root_directory)->build(); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-many-shapes.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "object", 5 | "required": ["foo"], 6 | "additionalProperties": false, 7 | "properties": { 8 | "foo": { 9 | "type": "integer" 10 | } 11 | } 12 | }, 13 | { 14 | "type": "object", 15 | "required": ["bar"], 16 | "additionalProperties": false, 17 | "properties": { 18 | "bar": { 19 | "type": "string" 20 | } 21 | } 22 | }, 23 | { 24 | "type": "object", 25 | "required": ["baz"], 26 | "additionalProperties": false, 27 | "properties": { 28 | "baz": { 29 | "type": "boolean" 30 | } 31 | } 32 | }, 33 | { 34 | "type": "object", 35 | "additionalProperties": false, 36 | "properties": { 37 | "foo": { 38 | "type": "null" 39 | } 40 | } 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExamplesStringSchemaDefinitionsHackEnum.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExamplesStringSchemaDefinitionsHackEnum 15 | extends JsonSchema\BaseValidator<\Slack\Hack\JsonSchema\Tests\TestStringEnum> { 16 | 17 | private static bool $coerce = false; 18 | 19 | public static function check( 20 | mixed $input, 21 | string $pointer, 22 | ): \Slack\Hack\JsonSchema\Tests\TestStringEnum { 23 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 24 | 25 | $typed = Constraints\HackEnumConstraint::check( 26 | $typed, 27 | \Slack\Hack\JsonSchema\Tests\TestStringEnum::class, 28 | $pointer, 29 | ); 30 | return $typed; 31 | } 32 | 33 | <<__Override>> 34 | protected function process(): \Slack\Hack\JsonSchema\Tests\TestStringEnum { 35 | return self::check($this->input, $this->pointer); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/examples/discard-additional-properties-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "only_additional_properties": { 5 | "type": "object", 6 | "additionalProperties": true, 7 | "discardAdditionalProperties": true 8 | }, 9 | "only_properties": { 10 | "type": "object", 11 | "additionalProperties": false, 12 | "discardAdditionalProperties": true, 13 | "required": ["required_string"], 14 | "properties": { 15 | "string": { "type": "string", "default": "optional_default" }, 16 | "number": { "type": "number", "default": 3 }, 17 | "required_string": { "type": "string", "default": "required_default" } 18 | } 19 | }, 20 | "additional_properties_ref": { 21 | "type": "object", 22 | "discardAdditionalProperties": true, 23 | "additionalProperties": { 24 | "$ref": "#/properties/additional_properties_array" 25 | } 26 | }, 27 | "additional_properties_array": { 28 | "type": "object", 29 | "discardAdditionalProperties": true, 30 | "additionalProperties": { 31 | "type": "array", 32 | "items": { 33 | "type": "string" 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/CircularReferenceTest.php: -------------------------------------------------------------------------------- 1 | $ret['codegen']->build())->toThrow(JsonSchema\CircularReferenceException::class); 13 | } 14 | 15 | public function testObjectiveLoop(): void { 16 | $ret = self::getBuilder('../external_examples/loop-schema2.json', 'CircularValidator'); 17 | expect(() ==> $ret['codegen']->build())->toThrow(JsonSchema\CircularReferenceException::class); 18 | } 19 | 20 | public function testDoubleObjectiveLoop(): void { 21 | $ret = self::getBuilder('../external_examples/loop-schema3.json', 'CircularValidator'); 22 | expect(() ==> $ret['codegen']->build())->toThrow(JsonSchema\CircularReferenceException::class); 23 | } 24 | 25 | public function testSimpleandObjectiveLoop(): void { 26 | $ret = self::getBuilder('../external_examples/loop-schema6.json', 'CircularValidator'); 27 | expect(() ==> $ret['codegen']->build())->toThrow(JsonSchema\CircularReferenceException::class); 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /tests/EmptySchemaValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 12 | public static async function beforeFirstTestAsync(): Awaitable { 13 | $ret = self::getBuilder('empty-schema.json', 'EmptySchemaValidator'); 14 | $ret['codegen']->build(); 15 | require_once($ret['path']); 16 | } 17 | 18 | public function provideMixedData(): vec<(mixed)> { 19 | return vec[ 20 | tuple(1), 21 | tuple('1'), 22 | tuple(vec[]), 23 | tuple(dict[]), 24 | tuple(darray[]), 25 | tuple(varray[]), 26 | tuple(Vector {}), 27 | tuple(Map {}), 28 | tuple(Set {}), 29 | tuple(keyset[]), 30 | tuple(new \stdClass()), 31 | tuple(false), 32 | tuple(null), 33 | ]; 34 | } 35 | 36 | <> 37 | public function testTrue(mixed $value): void { 38 | expect(() ==> { 39 | $schema = new Generated\EmptySchemaValidator($value); 40 | $schema->validate(); 41 | return $schema->getValidatedInput(); 42 | })->notToThrow('Anything should get passed the empty schema'); 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /tests/InvalidEnumTest.php: -------------------------------------------------------------------------------- 1 | '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 | } 45 | -------------------------------------------------------------------------------- /tests/external_examples/friends-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "title": "Friends", 4 | "type": "array", 5 | "items": { "$ref": "#/definitions/person" }, 6 | "definitions": { 7 | "person": { 8 | "type": "object", 9 | "properties": { 10 | "first_name": { 11 | "type": "string" 12 | }, 13 | "last_name": { 14 | "type": "string" 15 | }, 16 | "age": { "$ref": "#/definitions/age" }, 17 | "~devices/": { 18 | "type": "array", 19 | "items": { "$ref": "#/definitions/devices/phone" } 20 | } 21 | }, 22 | "required": ["first_name", "last_name"] 23 | }, 24 | "devices": { 25 | "phone": { 26 | "type": "object", 27 | "properties": { 28 | "model": { 29 | "type": "integer" 30 | }, 31 | "carrier/provider": { 32 | "type": "string" 33 | } 34 | }, 35 | "required": ["model"] 36 | } 37 | }, 38 | "nickname": { 39 | "$ref": "external_definitions.json#/nickname" 40 | }, 41 | "age": { 42 | "$ref": "../examples/person-schema.json#/properties/age" 43 | }, 44 | "relative": { 45 | "$ref": "external_definitions.json#/person" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Codegen/Typing/TypeAlias.hack: -------------------------------------------------------------------------------- 1 | namespace Slack\Hack\JsonSchema\Codegen\Typing; 2 | 3 | /** 4 | * An alias referencing another type in the type system. 5 | * 6 | * The referenced type may be an alias, an optional type, 7 | * or a concrete type. 8 | */ 9 | final class TypeAlias extends Type { 10 | public function __construct(private string $alias) {} 11 | 12 | private function getType(): Type { 13 | return TypeSystem::resolveAlias($this->alias) as nonnull; 14 | } 15 | 16 | <<__Override>> 17 | public function getConcreteTypeName(): ConcreteTypeName { 18 | return $this->getType()->getConcreteTypeName(); 19 | } 20 | 21 | <<__Override>> 22 | public function getGenerics(): vec { 23 | return $this->getType()->getGenerics(); 24 | } 25 | 26 | <<__Override>> 27 | public function getName(): string { 28 | return $this->alias; 29 | } 30 | 31 | <<__Override>> 32 | public function getShapeFields(): this::TShapeFields { 33 | return $this->getType()->getShapeFields(); 34 | } 35 | 36 | <<__Override>> 37 | public function hasAlias(): bool { 38 | return true; 39 | } 40 | 41 | <<__Override>> 42 | public function isClosedShape(): bool { 43 | return $this->getType()->isClosedShape(); 44 | } 45 | 46 | <<__Override>> 47 | public function isOptional(): bool { 48 | return $this->getType()->isOptional(); 49 | } 50 | } -------------------------------------------------------------------------------- /src/Constraints/HackEnumConstraint.php: -------------------------------------------------------------------------------- 1 | (mixed $input, \HH\enumname $enum_class, string $pointer): T { 18 | $typed = $enum_class::coerce($input); 19 | if ($typed is nonnull) { 20 | return $typed; 21 | } 22 | 23 | $error = shape( 24 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 25 | 'constraint' => shape( 26 | 'type' => JsonSchema\FieldErrorConstraint::ENUM, 27 | 'expected' => keyset($enum_class::getNames()), 28 | 'got' => $input, 29 | ), 30 | 'message' => 'must be a valid enum value', 31 | ); 32 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/TopLevelRefValidatorTest.hack: -------------------------------------------------------------------------------- 1 | namespace Slack\Hack\JsonSchema\Tests; 2 | 3 | use type Slack\Hack\JsonSchema\Tests\Generated\TopLevelRefValidator; 4 | 5 | final class TopLevelRefValidatorTest extends BaseCodegenTestCase { 6 | 7 | <<__Override>> 8 | public static async function beforeFirstTestAsync(): Awaitable { 9 | $ret = self::getBuilder( 10 | 'top-level-ref.json', 11 | 'TopLevelRefValidator', 12 | shape( 13 | 'refs' => shape( 14 | 'unique' => shape( 15 | 'source_root' => __DIR__, 16 | 'output_root' => __DIR__.'/examples/codegen', 17 | ), 18 | ), 19 | ), 20 | ); 21 | $ret['codegen']->build(); 22 | require_once($ret['path']); 23 | } 24 | 25 | public function testTopLevelRef(): void { 26 | $cases = vec[ 27 | shape( 28 | 'input' => darray['integer' => 1000], 29 | 'output' => darray['integer' => 1000], 30 | 'valid' => true, 31 | ), 32 | shape( 33 | 'input' => darray['integer' => 0], 34 | 'output' => darray['integer' => 0], 35 | 'valid' => true, 36 | ), 37 | shape('input' => darray['integer' => '1000'], 'valid' => false), 38 | shape('input' => darray['integer' => 1000.00], 'valid' => false), 39 | ]; 40 | 41 | $this->expectCases($cases, $input ==> new TopLevelRefValidator($input)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/examples/anyof-schema-nullable-nested-shapes.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "object", 5 | "required": ["foo"], 6 | "properties": { 7 | "foo": { 8 | "anyOf": [ 9 | { 10 | "type": "object", 11 | "additionalProperties": false, 12 | "properties": { 13 | "baz": { 14 | "type": "integer" 15 | } 16 | } 17 | }, 18 | { 19 | "type": "null" 20 | } 21 | ] 22 | } 23 | } 24 | }, 25 | { 26 | "type": "object", 27 | "required": ["foo"], 28 | "properties": { 29 | "foo": { 30 | "type": "object", 31 | "additionalProperties": false, 32 | "properties": { 33 | "baz": { 34 | "type": "string" 35 | }, 36 | "qux": { 37 | "type": "boolean" 38 | } 39 | } 40 | } 41 | } 42 | } 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /tests/examples/address-schema-remote.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "An Address following the convention of http://microformats.org/wiki/hcard", 4 | "type": "object", 5 | "properties": { 6 | "post-office-box": { "type": "string" }, 7 | "extended-address": { "type": "string" }, 8 | "street-address": { "type": "string" }, 9 | "locality":{ "type": "string" }, 10 | "region": { "type": "string" }, 11 | "phones": { 12 | "type": "array", 13 | "items": { "$ref": "../external_examples/friends-schema.json#/definitions/devices/phone" } 14 | }, 15 | "age": { 16 | "$ref": "../external_examples/friends-schema.json#/definitions/age" 17 | }, 18 | "nickname": { 19 | "$ref": "../external_examples/friends-schema.json#/definitions/nickname" 20 | }, 21 | "relative": { 22 | "$ref": "../external_examples/friends-schema.json#/definitions/relative" 23 | }, 24 | "postal-code": { 25 | "anyOf": [ 26 | { "type": "string" }, 27 | { "type": "integer" } 28 | ] 29 | }, 30 | "country-name": { "type": "string"} 31 | }, 32 | "required": ["locality", "region", "country-name"], 33 | "dependencies": { 34 | "post-office-box": ["street-address"], 35 | "extended-address": ["street-address"] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Codegen/Constraints/NullBuilder.php: -------------------------------------------------------------------------------- 1 | TSchemaType, 10 | ... 11 | ); 12 | 13 | class NullBuilder extends BaseBuilder { 14 | protected static string $schema_name = 'Slack\Hack\JsonSchema\Codegen\TNullSchema'; 15 | 16 | <<__Override>> 17 | public function build(): this { 18 | $class = $this->codegenClass() 19 | ->addMethod($this->getCheckMethod()); 20 | 21 | $properties = vec[]; 22 | 23 | $class->addProperties($properties); 24 | $this->addBuilderClass($class); 25 | 26 | return $this; 27 | } 28 | 29 | protected function getCheckMethod(): CodegenMethod { 30 | $hb = $this->getHackBuilder() 31 | ->addAssignment('$typed', 'Constraints\NullConstraint::check($input, $pointer)', HackBuilderValues::literal()) 32 | ->ensureEmptyLine(); 33 | 34 | $hb->addReturn('$typed', HackBuilderValues::literal()); 35 | 36 | return $this->codegenCheckMethod() 37 | ->addParameters(vec['mixed $input', 'string $pointer']) 38 | ->setBody($hb->getCode()) 39 | ->setReturnType($this->getType()); 40 | } 41 | 42 | <<__Override>> 43 | public function getType(): string { 44 | return 'null'; 45 | } 46 | 47 | <<__Override>> 48 | public function getTypeInfo(): Typing\Type { 49 | return Typing\TypeSystem::null(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Codegen/RefCache.php: -------------------------------------------------------------------------------- 1 | string, 10 | 'classname' => string, 11 | 'isArrayKeyType' => bool, 12 | 'typeInfo' => Typing\Type, 13 | ); 14 | 15 | static private dict $generatedRefs = dict[]; 16 | 17 | // 18 | // Run this resetCache before using any other method 19 | // 20 | 21 | static public function resetCache(): void { 22 | RefCache::$generatedRefs = dict[]; 23 | } 24 | 25 | // 26 | // Cache the ref by passing in a Cached Ref. The $refName should be 27 | // the full pathname of the file. 28 | // 29 | 30 | static public function cacheRef(string $refName, self::TCachedRef $ref): void { 31 | RefCache::$generatedRefs[$refName] = $ref; 32 | } 33 | 34 | // 35 | // Check if a CodegenFile already exists for the particular $refName 36 | // 37 | 38 | static public function isRefCached(string $refName): bool { 39 | return C\contains_key(RefCache::$generatedRefs, $refName); 40 | } 41 | 42 | // 43 | // Do not use this function unless you have already verified that a 44 | // Cached Ref exists for $refName. This function will not gracefully 45 | // handle the case when $refName is not found in the cache. 46 | // 47 | 48 | static public function getCachedRef(string $refName): this::TCachedRef { 49 | return RefCache::$generatedRefs[$refName]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Constraints/ArrayConstraint.php: -------------------------------------------------------------------------------- 1 | { 12 | if ($coerce && $input is string) { 13 | $coerced = JsonSchema\json_decode_hack($input); 14 | 15 | // Different OSes can parse JSON differently. If `$coerced` is null, we 16 | // assume JSON decoding failed. If `$coerced` is not a `Traversable`, it 17 | // failed decoding to an array. To support form encoded arrays, we'll 18 | // check to see if we can create an array from a comma delimited string. 19 | $valid_json = $coerced is nonnull && $coerced is Traversable<_>; 20 | if (!$valid_json) { 21 | $coerced = Str\split($input, ','); 22 | } 23 | 24 | $input = $coerced; 25 | } 26 | 27 | $spec = TypeSpec\vec(TypeSpec\mixed()); 28 | try { 29 | // To allow for either PHP or hack arrays, we coerce to a vec here. 30 | return $spec->coerceType($input); 31 | } catch (TypeCoercionException $e) { 32 | $error = shape( 33 | 'code' => JsonSchema\FieldErrorCode::INVALID_TYPE, 34 | 'message' => 'must provide an array', 35 | ); 36 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Codegen/Typing/OptionalType.hack: -------------------------------------------------------------------------------- 1 | namespace Slack\Hack\JsonSchema\Codegen\Typing; 2 | 3 | /** 4 | * Represents an optional type in the Hack type system. 5 | * 6 | * In Hack, any concrete type can be made optional. When a type is optional, 7 | * it can be satisfied by `null`, which is the absence of a value. For example, 8 | * the type T can be made optional and is then written as ?T. The type `nonnull`, 9 | * when made optional, is written as `mixed`. 10 | */ 11 | final class OptionalType extends Type { 12 | public function __construct(private Type $type) {} 13 | 14 | public function getType(): Type { 15 | return $this->type; 16 | } 17 | 18 | <<__Override>> 19 | public function getConcreteTypeName(): ConcreteTypeName { 20 | return $this->type->getConcreteTypeName(); 21 | } 22 | 23 | <<__Override>> 24 | public function getGenerics(): vec { 25 | return $this->type->getGenerics(); 26 | } 27 | 28 | <<__Override>> 29 | public function getName(): string { 30 | return '?'.$this->type->getName(); 31 | } 32 | 33 | <<__Override>> 34 | public function getShapeFields(): this::TShapeFields { 35 | return $this->type->getShapeFields(); 36 | } 37 | 38 | <<__Override>> 39 | public function hasAlias(): bool { 40 | return $this->type->hasAlias(); 41 | } 42 | 43 | <<__Override>> 44 | public function isClosedShape(): bool { 45 | return $this->type->isClosedShape(); 46 | } 47 | 48 | <<__Override>> 49 | public function isOptional(): bool { 50 | return true; 51 | } 52 | } -------------------------------------------------------------------------------- /tests/CodegenForPathsTest.php: -------------------------------------------------------------------------------- 1 | shape( 19 | 'namespace' => self::CODEGEN_NS, 20 | 'refs' => shape( 21 | 'unique' => shape( 22 | 'source_root' => __DIR__, 23 | 'output_root' => __DIR__.'/examples/codegen', 24 | ), 25 | ), 26 | 'sanitize_string' => shape( 27 | 'uniline' => _string_schema_validator_test_uniline<>, 28 | 'multiline' => _string_schema_validator_test_multiline<>, 29 | ), 30 | ), 31 | 'generatedFrom' => '`make test`', 32 | ); 33 | 34 | $codegens = Codegen::forPaths($paths, $config); 35 | foreach ($codegens as $codegen) { 36 | $codegen->build(); 37 | } 38 | 39 | $validators = vec[ 40 | new ExamplesStringSchema(dict[]), 41 | new ExternalExamplesFriendsSchema(dict[]), 42 | ]; 43 | foreach ($validators as $validator) { 44 | $validator->validate(); 45 | } 46 | } 47 | 48 | } 49 | -------------------------------------------------------------------------------- /tests/examples/address-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "description": "An Address following the convention of http://microformats.org/wiki/hcard", 4 | "type": "object", 5 | "properties": { 6 | "post-office-box": { "type": "string" }, 7 | "extended-address": { "type": "string" }, 8 | "street-address": { "type": "string" }, 9 | "locality":{ "type": "string" }, 10 | "region": { "type": "string" }, 11 | "phones": { 12 | "type": "array", 13 | "items": { "$ref": "friends-schema.json#/definitions/devices/phone" } 14 | }, 15 | "postal-code": { 16 | "anyOf": [ 17 | { "type": "string" }, 18 | { "type": "integer" } 19 | ] 20 | }, 21 | "size" : { 22 | "allOf" : [ 23 | { "type" : "integer" }, 24 | { "type" : "number" } 25 | ] 26 | }, 27 | "longitude" : { 28 | "not" : [ 29 | { "type" : "integer" }, 30 | { "type" : "string" } 31 | ] 32 | }, 33 | "latitude" : { 34 | "oneOf" : [ 35 | { "type" : "integer" }, 36 | { "type" : "number" } 37 | ] 38 | }, 39 | "country-name": { "type": "string"} 40 | }, 41 | "required": ["locality", "region", "country-name"], 42 | "dependencies": { 43 | "post-office-box": ["street-address"], 44 | "extended-address": ["street-address"] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/BaseValidator.php: -------------------------------------------------------------------------------- 1 | > 8 | abstract class BaseValidator<+T> implements Validator { 9 | private vec $errors = vec[]; 10 | <<__LateInit>> private T $validated_input; 11 | private bool $has_been_validated = false; 12 | 13 | public function __construct(protected mixed $input, protected string $pointer = '') {} 14 | 15 | abstract protected function process(): T; 16 | 17 | final public function validate(): void { 18 | try { 19 | $this->validated_input = $this->process(); 20 | } catch (InvalidFieldException $e) { 21 | $this->errors = $e->errors; 22 | } 23 | $this->has_been_validated = true; 24 | } 25 | 26 | final public function isValid(): bool { 27 | return C\is_empty($this->errors); 28 | } 29 | 30 | final public function getErrors(): vec { 31 | return $this->errors; 32 | } 33 | 34 | final public function getValidatedInput(): T { 35 | if (!$this->has_been_validated) { 36 | throw new \Exception('Must call `validate` before accessing validated input.'); 37 | } 38 | if (!$this->isValid()) { 39 | throw new \Exception("Can't access validated input since validator is invalid."); 40 | } 41 | 42 | return $this->validated_input; 43 | } 44 | 45 | } 46 | 47 | interface Validator<+T> { 48 | public function validate(): void; 49 | public function isValid(): bool; 50 | public function getErrors(): vec; 51 | public function getValidatedInput(): ?T; 52 | } 53 | -------------------------------------------------------------------------------- /tests/examples/allof-schema-2.json: -------------------------------------------------------------------------------- 1 | { 2 | "allOf": [ 3 | {"$ref": "./allof-schema-1.json#"}, 4 | {"$ref": "../external_examples/external_definitions.json#person"}, 5 | {"$ref": "../external_examples/simple-object-ref.json#"}, 6 | { 7 | "type": "object", 8 | "additionalProperties": false, 9 | "required": ["nickname"] 10 | }, 11 | { 12 | "type": "object", 13 | "additionalProperties": false, 14 | "properties": { 15 | "baz": { 16 | "type": "integer", 17 | "multipleOf": 3 18 | }, 19 | "xs": { 20 | "type": "array", 21 | "items": { 22 | "allOf": [ 23 | { 24 | "$ref": "./allof-schema-1.json#" 25 | }, 26 | { 27 | "type": "object", 28 | "additionalProperties": false, 29 | "properties": { 30 | "qux": { 31 | "type": "integer" 32 | } 33 | }, 34 | "required": ["qux"] 35 | } 36 | ] 37 | } 38 | } 39 | }, 40 | "required": ["baz", "xs"] 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /tests/examples/anyof-nested-anyof.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyOf": [ 3 | { 4 | "type": "object", 5 | "additionalProperties": false, 6 | "properties": { 7 | "bar": { 8 | "anyOf": [ 9 | { 10 | "$ref": "#/definitions/testObj" 11 | }, 12 | { 13 | "type": "string" 14 | }, 15 | { 16 | "type": "null" 17 | } 18 | ] 19 | } 20 | } 21 | }, 22 | { 23 | "type": "object", 24 | "additionalProperties": false, 25 | "properties": { 26 | "bar": { 27 | "anyOf": [ 28 | { 29 | "$ref": "#/definitions/testObj" 30 | }, 31 | { 32 | "type": "string" 33 | }, 34 | { 35 | "type": "null" 36 | } 37 | ] 38 | } 39 | } 40 | } 41 | ], 42 | "definitions": { 43 | "testObj": { 44 | "type": "object", 45 | "additionalProperties": false, 46 | "properties": { 47 | "qux": { 48 | "type": "string" 49 | } 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /tests/UtilsTest.php: -------------------------------------------------------------------------------- 1 | toBeSame('/foo'); 18 | 19 | $ret = get_pointer(get_pointer('', 'foo'), (string)0); 20 | expect($ret)->toBeSame('/foo/0'); 21 | 22 | $ret = get_pointer('', ''); 23 | expect($ret)->toBeSame('/'); 24 | 25 | $ret = get_pointer('', 'a/b'); 26 | expect($ret)->toBeSame('/a~1b'); 27 | 28 | $ret = get_pointer('', 'c%d'); 29 | expect($ret)->toBeSame('/c%d'); 30 | 31 | $ret = get_pointer('', 'e^f'); 32 | expect($ret)->toBeSame('/e^f'); 33 | 34 | $ret = get_pointer('', 'g|h'); 35 | expect($ret)->toBeSame('/g|h'); 36 | 37 | $ret = get_pointer('', 'i\\j'); 38 | expect($ret)->toBeSame('/i\\j'); 39 | 40 | $ret = get_pointer('', "k\"1"); 41 | expect($ret)->toBeSame("/k\"1"); 42 | 43 | $ret = get_pointer('', ' '); 44 | expect($ret)->toBeSame('/ '); 45 | 46 | $ret = get_pointer('', 'm~n'); 47 | expect($ret)->toBeSame('/m~0n'); 48 | 49 | $ret = get_pointer('', 'foo', 'bar', 'baz'); 50 | expect($ret)->toBeSame('/foo/bar/baz'); 51 | 52 | $ret = get_pointer('/foo', 'bar', 'baz'); 53 | expect($ret)->toBeSame('/foo/bar/baz'); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /tests/GeoSchemaValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 11 | public static async function beforeFirstTestAsync(): Awaitable { 12 | $ret = self::getBuilder('geo-schema.json', 'GeoSchemaValidator'); 13 | $ret['codegen']->build(); 14 | /* HH_IGNORE_ERROR[1002] intentionally using require_once outside of top-level */ 15 | require_once($ret['path']); 16 | } 17 | 18 | public function testValidateValidInput(): void { 19 | $validator = new GeoSchemaValidator(dict['latitude' => 37.7749, 'longitude' => 122.4194]); 20 | $validator->validate(); 21 | 22 | expect($validator->isValid())->toBeTrue(); 23 | } 24 | 25 | public function testValidateMissingFields(): void { 26 | $validator = new GeoSchemaValidator(dict[]); 27 | $validator->validate(); 28 | 29 | expect($validator->isValid())->toBeFalse(); 30 | } 31 | 32 | public function testValidateLatitudeOverMaximum(): void { 33 | $validator = new GeoSchemaValidator(dict['latitude' => 180, 'longitude' => 120]); 34 | $validator->validate(); 35 | 36 | expect($validator->isValid())->toBeFalse(); 37 | } 38 | 39 | public function testValidateLongitudeOverMaximum(): void { 40 | $validator = new GeoSchemaValidator(dict['latitude' => 37, 'longitude' => 190]); 41 | $validator->validate(); 42 | 43 | expect($validator->isValid())->toBeFalse(); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/Codegen/Typing/ConcreteType.hack: -------------------------------------------------------------------------------- 1 | namespace Slack\Hack\JsonSchema\Codegen\Typing; 2 | 3 | use Facebook\HackCodegen\CodegenType; 4 | 5 | /** 6 | * Represents a concrete type in the Hack type system, 7 | * i.e., a type which cannot be satisfied by `null` 8 | * and which is not a type alias. 9 | * 10 | * Concrete types may contain generics. It may be possible to also 11 | * represent optional types as concrete types containing a single 12 | * generic, but using a separate type for optionals seemed like a simpler 13 | * approach for now. 14 | */ 15 | final class ConcreteType extends Type { 16 | public function __construct( 17 | private ConcreteTypeName $name, 18 | private vec $generics = vec[], 19 | private this::TShapeFields $shape_fields = dict[], 20 | private bool $is_closed_shape = false, 21 | ) {} 22 | 23 | <<__Override>> 24 | public function getConcreteTypeName(): ConcreteTypeName { 25 | return $this->name; 26 | } 27 | 28 | <<__Override>> 29 | public function getGenerics(): vec { 30 | return $this->generics; 31 | } 32 | 33 | <<__Override>> 34 | public function getName(): string { 35 | return (string)$this->getConcreteTypeName(); 36 | } 37 | 38 | <<__Override>> 39 | public function getShapeFields(): this::TShapeFields { 40 | return $this->shape_fields; 41 | } 42 | 43 | <<__Override>> 44 | public function hasAlias(): bool { 45 | return false; 46 | } 47 | 48 | <<__Override>> 49 | public function isClosedShape(): bool { 50 | return $this->is_closed_shape; 51 | } 52 | 53 | <<__Override>> 54 | public function isOptional(): bool { 55 | return false; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/EnumSchemaValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 11 | public static async function beforeFirstTestAsync(): Awaitable { 12 | $ret = self::getBuilder('enum-schema.json', 'EnumSchemaValidator'); 13 | $ret['codegen']->build(); 14 | require_once($ret['path']); 15 | } 16 | 17 | public function testStringEnum(): void { 18 | $cases = vec[ 19 | shape( 20 | 'input' => darray['enum_string' => 'one'], 21 | 'output' => darray['enum_string' => 'one'], 22 | 'valid' => true, 23 | ), 24 | shape( 25 | 'input' => darray['enum_string' => 'four'], 26 | 'valid' => false, 27 | ), 28 | shape( 29 | 'input' => darray['enum_string' => 1], 30 | 'valid' => false, 31 | ), 32 | ]; 33 | 34 | $this->expectCases($cases, $input ==> new EnumSchemaValidator($input)); 35 | } 36 | 37 | public function testNumberEnum(): void { 38 | $cases = vec[ 39 | shape( 40 | 'input' => darray['enum_number' => 1], 41 | 'output' => darray['enum_number' => 1], 42 | 'valid' => true, 43 | ), 44 | shape( 45 | 'input' => darray['enum_number' => 'four'], 46 | 'valid' => false, 47 | ), 48 | shape( 49 | 'input' => darray['enum_number' => 4], 50 | 'valid' => false, 51 | ), 52 | ]; 53 | 54 | $this->expectCases($cases, $input ==> new EnumSchemaValidator($input)); 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExternalExamplesFriendsSchema.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | final class ExternalExamplesFriendsSchema 15 | extends JsonSchema\BaseValidator> { 16 | 17 | private static bool $coerce = false; 18 | 19 | public static function check( 20 | mixed $input, 21 | string $pointer, 22 | ): vec { 23 | $typed = Constraints\ArrayConstraint::check($input, $pointer, self::$coerce); 24 | 25 | $output = vec[]; 26 | $errors = vec[]; 27 | 28 | foreach ($typed as $index => $value) { 29 | try { 30 | $output[] = ExternalExamplesFriendsSchemaDefinitionsPerson::check( 31 | $value, 32 | JsonSchema\get_pointer($pointer, (string) $index), 33 | ); 34 | } catch (JsonSchema\InvalidFieldException $e) { 35 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 36 | } 37 | } 38 | 39 | if (\HH\Lib\C\count($errors)) { 40 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 41 | } 42 | 43 | return $output; 44 | } 45 | 46 | <<__Override>> 47 | protected function process( 48 | ): vec { 49 | return self::check($this->input, $this->pointer); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/examples/codegen/AnyOfValidator3.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | 13 | type TAnyOfValidator3 = null; 14 | 15 | final class AnyOfValidator3 extends JsonSchema\BaseValidator { 16 | 17 | public static function check( 18 | mixed $input, 19 | string $pointer, 20 | ): TAnyOfValidator3 { 21 | if ($input === null) { 22 | return null; 23 | } 24 | 25 | $constraints = vec[ 26 | ]; 27 | $errors = vec[ 28 | ]; 29 | 30 | foreach ($constraints as $constraint) { 31 | try { 32 | $output = $constraint($input, $pointer); 33 | return $output; 34 | } catch (JsonSchema\InvalidFieldException $e) { 35 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 36 | } 37 | } 38 | 39 | $error = shape( 40 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 41 | 'constraint' => shape( 42 | 'type' => JsonSchema\FieldErrorConstraint::ANY_OF, 43 | ), 44 | 'message' => 'failed to match any allowed schemas', 45 | ); 46 | 47 | $output_errors = vec[$error]; 48 | $output_errors = \HH\Lib\Vec\concat($output_errors, $errors); 49 | throw new JsonSchema\InvalidFieldException($pointer, $output_errors); 50 | } 51 | 52 | <<__Override>> 53 | protected function process(): TAnyOfValidator3 { 54 | return self::check($this->input, $this->pointer); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Codegen/Utils.php: -------------------------------------------------------------------------------- 1 | > 9 | /* HH_IGNORE_ERROR[4030] `getResolvedTypeStructure` returns an `array` */ 10 | function _type_assert_get_type_structure(string $type) { 11 | $s = new \ReflectionTypeAlias($type); 12 | return $s->getResolvedTypeStructure(); 13 | } 14 | 15 | function type_assert_type(mixed $var, typename $type): T { 16 | $ts = _type_assert_get_type_structure($type); 17 | $result = TypeAssert\matches_type_structure($ts, $var); 18 | return $result; 19 | } 20 | 21 | function type_assert_shape(mixed $var, string $shape): T { 22 | $ts = _type_assert_get_type_structure($shape); 23 | $result = TypeAssert\matches_type_structure($ts, $var); 24 | return $result; 25 | } 26 | 27 | function sanitize(string $input): string { 28 | return $input 29 | |> Str\replace_every($$, dict['_' => ' ', '-' => ' ', '.' => ' ']) 30 | |> \preg_replace('/[^A-Za-z0-9 ]/', '_nan_', $$) 31 | |> Str\capitalize_words($$, " \t\r\n\f\v") 32 | |> Str\replace($$, ' ', ''); 33 | } 34 | 35 | function format(string ...$parts): string { 36 | return Vec\map($parts, sanitize<>) 37 | |> Str\join($$, ''); 38 | } 39 | 40 | /** 41 | * Hacky "temporary" method that allows us to inspect codegen classes. We need 42 | * to add getters to hack-codegen to allow us to do this introspection without 43 | * reflection. 44 | */ 45 | function get_private_property(string $class_name, string $property_name, mixed $instance): mixed { 46 | $class = new \ReflectionClass($class_name); 47 | $property = $class->getProperty($property_name); 48 | $property->setAccessible(true); 49 | return $property->getValue($instance); 50 | } 51 | -------------------------------------------------------------------------------- /src/Codegen/Constraints/BooleanBuilder.php: -------------------------------------------------------------------------------- 1 | TSchemaType, 9 | ?'coerce' => bool, 10 | ... 11 | ); 12 | 13 | class BooleanBuilder extends BaseBuilder { 14 | protected static string $schema_name = 'Slack\Hack\JsonSchema\Codegen\TBooleanSchema'; 15 | 16 | <<__Override>> 17 | public function build(): this { 18 | 19 | $properties = vec[]; 20 | 21 | $coerce = $this->typed_schema['coerce'] ?? $this->ctx->getCoerceDefault(); 22 | $properties[] = $this->codegenProperty('coerce') 23 | ->setType('bool') 24 | ->setValue($coerce, HackBuilderValues::export()); 25 | 26 | $class = $this->codegenClass() 27 | ->addMethod($this->getCheckMethod()); 28 | $class->addProperties($properties); 29 | 30 | $this->addBuilderClass($class); 31 | return $this; 32 | } 33 | 34 | <<__Override>> 35 | public function getType(): string { 36 | return 'bool'; 37 | } 38 | 39 | protected function getCheckMethod(): CodegenMethod { 40 | $hb = $this->getHackBuilder() 41 | ->addAssignment( 42 | '$typed', 43 | 'Constraints\BooleanConstraint::check($input, $pointer, self::$coerce)', 44 | HackBuilderValues::literal(), 45 | ) 46 | ->addReturn('$typed', HackBuilderValues::literal()); 47 | 48 | return $this->codegenCheckMethod() 49 | ->addParameters(vec['mixed $input', 'string $pointer']) 50 | ->setBody($hb->getCode()) 51 | ->setReturnType($this->getType()); 52 | } 53 | 54 | <<__Override>> 55 | public function getTypeInfo(): Typing\Type { 56 | return Typing\TypeSystem::bool(); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/examples/friends-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "title": "Friends", 4 | "type": "array", 5 | "items": { "$ref": "#/definitions/person" }, 6 | "definitions": { 7 | "person": { 8 | "type": "object", 9 | "properties": { 10 | "first_name": { 11 | "type": "string" 12 | }, 13 | "last_name": { 14 | "type": "string" 15 | }, 16 | "age": { 17 | "type": "integer", 18 | "minimum": 0 19 | }, 20 | "~devices/": { 21 | "type": "array", 22 | "items": { "$ref": "#/definitions/devices/phone" } 23 | }, 24 | "enemies" : { 25 | "type" : "null" 26 | }, 27 | "rating" : { 28 | "type" : "array", 29 | "items" : [ 30 | { 31 | "type" : "integer" 32 | }, 33 | { 34 | "type" : "string" 35 | } 36 | ], 37 | "additionalItems" : false 38 | }, 39 | "contact" : { 40 | "type" : "array", 41 | "items" : [ 42 | { 43 | "type" : "integer" 44 | }, 45 | { 46 | "type" : "string" 47 | } 48 | ] 49 | }, 50 | "empty_object": { 51 | "type": "object" 52 | } 53 | }, 54 | "required": ["first_name", "last_name"] 55 | }, 56 | "devices": { 57 | "phone": { 58 | "type": "object", 59 | "properties": { 60 | "model": { 61 | "type": "string" 62 | }, 63 | "carrier/provider": { 64 | "type": "string" 65 | } 66 | }, 67 | "required": ["model"] 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /tests/examples/codegen/AnyOfValidator1.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TAnyOfValidator1 = ?string; 15 | 16 | final class AnyOfValidator1AnyOf0 { 17 | 18 | private static bool $coerce = false; 19 | 20 | public static function check(mixed $input, string $pointer): string { 21 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 22 | 23 | return $typed; 24 | } 25 | } 26 | 27 | final class AnyOfValidator1 extends JsonSchema\BaseValidator { 28 | 29 | public static function check( 30 | mixed $input, 31 | string $pointer, 32 | ): TAnyOfValidator1 { 33 | if ($input === null) { 34 | return null; 35 | } 36 | 37 | $constraints = vec[ 38 | AnyOfValidator1AnyOf0::check<>, 39 | ]; 40 | $errors = vec[ 41 | ]; 42 | 43 | foreach ($constraints as $constraint) { 44 | try { 45 | $output = $constraint($input, $pointer); 46 | return $output; 47 | } catch (JsonSchema\InvalidFieldException $e) { 48 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 49 | } 50 | } 51 | 52 | $error = shape( 53 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 54 | 'constraint' => shape( 55 | 'type' => JsonSchema\FieldErrorConstraint::ANY_OF, 56 | ), 57 | 'message' => 'failed to match any allowed schemas', 58 | ); 59 | 60 | $output_errors = vec[$error]; 61 | $output_errors = \HH\Lib\Vec\concat($output_errors, $errors); 62 | throw new JsonSchema\InvalidFieldException($pointer, $output_errors); 63 | } 64 | 65 | <<__Override>> 66 | protected function process(): TAnyOfValidator1 { 67 | return self::check($this->input, $this->pointer); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /tests/CustomCodegenConfigTest.php: -------------------------------------------------------------------------------- 1 | shape( 17 | 'uniline' => \Slack\Hack\JsonSchema\Codegen\format<>, 18 | 'multiline' => \Slack\Hack\JsonSchema\Codegen\format<>, 19 | ), 20 | 'json_schema_codegen_config' => $config, 21 | ), 22 | ); 23 | $ret['codegen']->build(); 24 | $cf = $ret['codegen']->getFile(); 25 | 26 | $rendered = $cf->render(); 27 | $this->assertUnchanged($rendered); 28 | } 29 | 30 | } 31 | 32 | final class CustomCodegenConfig implements IJsonSchemaCodegenConfig { 33 | public function getTypeNamePrefix(): string { 34 | return ''; 35 | } 36 | 37 | public function getTypeNameSuffix(): string { 38 | return '_t'; 39 | } 40 | 41 | public function getClassNameFormatFunction(): (function(string...): string) { 42 | return \Slack\Hack\JsonSchema\Codegen\format<>; 43 | } 44 | 45 | public function getTypeNameFormatFunction(): (function(string...): string) { 46 | return (string ...$parts) ==> { 47 | return Vec\map($parts, inst_meth($this, 'sanitize')) 48 | |> Str\join($$, '_'); 49 | }; 50 | } 51 | 52 | public function getFileNameFormatFunction(): (function(string...): string) { 53 | return \Slack\Hack\JsonSchema\Codegen\format<>; 54 | } 55 | 56 | public function sanitize(string $input): string { 57 | return $input 58 | |> Str\replace_every($$, dict['_' => ' ', '-' => ' ', '.' => ' ']) 59 | |> \preg_split('/(?=[A-Z])/', $$) 60 | |> \array_filter($$) 61 | |> Vec\map($$, Str\lowercase<>) 62 | |> Str\join($$, ' ') 63 | |> \preg_replace('/[^A-Za-z0-9 ]/', ' nan ', $$) 64 | |> Str\replace($$, ' ', '_'); 65 | } 66 | 67 | } 68 | -------------------------------------------------------------------------------- /tests/examples/array-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "array_of_strings": { 5 | "type": "array", 6 | "items": { 7 | "type": "string" 8 | } 9 | }, 10 | "untyped_array": { 11 | "type": "array" 12 | }, 13 | "coerce_array": { 14 | "type": "array", 15 | "coerce": true, 16 | "items": { 17 | "type": "number", 18 | "coerce": true 19 | } 20 | }, 21 | "unique_strings": { 22 | "type": "array", 23 | "uniqueItems": true, 24 | "items": { 25 | "type": "string" 26 | } 27 | }, 28 | "unique_strings_ref": { 29 | "type": "array", 30 | "uniqueItems": true, 31 | "items": { 32 | "$ref": "string-schema.json#/definitions/simple" 33 | } 34 | }, 35 | "unique_numbers": { 36 | "type": "array", 37 | "uniqueItems": true, 38 | "items": { 39 | "type": "integer" 40 | } 41 | }, 42 | "unique_numbers_coerce": { 43 | "type": "array", 44 | "uniqueItems": true, 45 | "coerce": true, 46 | "items": { 47 | "type": "integer" 48 | } 49 | }, 50 | "unsupported_unique_items": { 51 | "type": "array", 52 | "uniqueItems": true, 53 | "items": { 54 | "type": "object", 55 | "properties": { 56 | "foo": { 57 | "type": "string" 58 | } 59 | } 60 | } 61 | }, 62 | "hack_enum_items": { 63 | "type": "array", 64 | "items": { 65 | "type": "string", 66 | "hackEnum": "Slack\\Hack\\JsonSchema\\Tests\\TestStringEnum" 67 | } 68 | }, 69 | "unique_hack_enum_items": { 70 | "type": "array", 71 | "uniqueItems": true, 72 | "coerce": true, 73 | "items": { 74 | "type": "string", 75 | "hackEnum": "Slack\\Hack\\JsonSchema\\Tests\\TestStringEnum" 76 | } 77 | }, 78 | "unique_hack_enum_items_ref": { 79 | "type": "array", 80 | "uniqueItems": true, 81 | "coerce": true, 82 | "items": { 83 | "$ref": "string-schema.json#/definitions/hack_enum" 84 | } 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExamplesRefSchemaDefinitionsLocalObjectReference.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TExamplesRefSchemaDefinitionsLocalObjectReference = shape( 15 | ?'first' => string, 16 | ... 17 | ); 18 | 19 | final class ExamplesRefSchemaDefinitionsLocalObjectReference 20 | extends JsonSchema\BaseValidator { 21 | 22 | private static bool $coerce = false; 23 | 24 | public static function check( 25 | mixed $input, 26 | string $pointer, 27 | ): TExamplesRefSchemaDefinitionsLocalObjectReference { 28 | $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); 29 | 30 | $errors = vec[]; 31 | $output = shape(); 32 | 33 | /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ 34 | foreach ($typed as $key => $value) { 35 | /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ 36 | $output[$key] = $value; 37 | } 38 | 39 | if (\HH\Lib\C\contains_key($typed, 'first')) { 40 | try { 41 | $output['first'] = ExamplesRefSchemaDefinitionsLocalPropertyReference::check( 42 | $typed['first'], 43 | JsonSchema\get_pointer($pointer, 'first'), 44 | ); 45 | } catch (JsonSchema\InvalidFieldException $e) { 46 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 47 | } 48 | } 49 | 50 | if (\HH\Lib\C\count($errors)) { 51 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 52 | } 53 | 54 | /* HH_IGNORE_ERROR[4163] */ 55 | return $output; 56 | } 57 | 58 | <<__Override>> 59 | protected function process( 60 | ): TExamplesRefSchemaDefinitionsLocalObjectReference { 61 | return self::check($this->input, $this->pointer); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Constraints/NumberMultipleOfConstraint.php: -------------------------------------------------------------------------------- 1 | 0, 'multipleOf 0 or a negative number does not make sense. Use a positive non-zero number.'); 19 | 20 | $remainer = 21 | Math\abs($dividend is int && $devisor is int ? $dividend % $devisor : \fmod((float)$dividend, (float)$devisor)); 22 | 23 | $error = shape( 24 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 25 | 'constraint' => shape( 26 | 'type' => JsonSchema\FieldErrorConstraint::MULTIPLE_OF, 27 | 'expected' => $devisor, 28 | 'got' => $dividend, 29 | ), 30 | 'message' => Str\format('must be a multiple of %s', (string)$devisor), 31 | ); 32 | 33 | if ($remainer is int && $remainer !== 0) { 34 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 35 | } 36 | if ($remainer is float) { 37 | // If we are closer to 0 than to the devisor, we check assert that our remainer 38 | // is less than our COMPARISON_LEEWAY. 39 | if ($remainer < $devisor / 2 && $remainer > COMPARISON_LEEWAY) { 40 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 41 | // However, sometimes the remainer is very close to the devisor. 42 | // That could also indicate a multiple of the devisor. 43 | } else if ($remainer > $devisor / 2 && ($devisor - $remainer) > COMPARISON_LEEWAY) { 44 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.github/workflows/hhvm.gpg.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | Version: GnuPG v1 3 | 4 | mQINBFn8koEBEAC2tPtkphj8gZYHI9mTNUHfQalDo+MNWTGUTNB42asjhTNjipzM 5 | VSxjaZSl5cMLg5YCRuT0AbSIe529FH23yEElc03cGVGgoEnmXtE4+2v7Xa30wCGO 6 | 5oUxKfbVatsxEs1y8QEr5Gt+CUFmsApOKgiZq0MsPYmFAuC9CbWdXYa8+E00bXOa 7 | cHCpe+GncCxQmExm7TlrUnURnf3RnNWSEkuPKED/aVggzxNVN6RgRRm4ssZJasM3 8 | TwoI1nVysO5jMfPClvupYscoktO44HBZzH2EeEdpjSV+toD3aZCbmWzXyZjogrFN 9 | j4k5Mme0Xqr4DvRPk5M9SxcQASsCQ8VTyu+ZBUG6zJbddLDEA1BMNIZOG5MyX58O 10 | zed255Q85SAyjHu8pltkfGLd56+MYsckpHaBPMFoCFM4iPcpXOlgcU96pdXJbrR2 11 | mjYI4Le9qRJYYP2kGPkopPwK8nbZJ5Wr7xaclxEc/ODH3mv57KJD7lzmwpnvvmsn 12 | kR/wUHOqwrXojp/oZCUK8KembLiT+MMkY3bne+IY9ef/1qwu4flVBP1CpoaMQEwh 13 | dqzihfwyQ+57ATZHJaj8V9pKAxWh/Df4iFN5mMWA15eBLhRMbAWKJIoLQLcCYwBF 14 | gH3HiO34/uQUHaX6VhRHllA38WUoZNhKmw/Kcd/FDQWlbzbgmI89LJEJuwARAQAB 15 | tC1ISFZNIFBhY2thZ2UgU2lnbmluZyA8b3BlbnNvdXJjZStoaHZtQGZiLmNvbT6J 16 | Ak4EEwEIADgWIQQFg0HGj8jeYBfXdaG0ESWF04brlAUCWfySgQIbAwULCQgHAgYV 17 | CAkKCwIEFgIDAQIeAQIXgAAKCRC0ESWF04brlMp8D/4ia7wLi6OQEtR8uPIrtCdg 18 | ClHvXTX0zihHPDomn77lRSfqEVapKcsvpyc9YTjv27EuRvymUG+o7971RY+rYes4 19 | +POdsjlxJF5ZkNi8YxpUNEw2hTWC66o6vd4Gv4dJgugkZ5dvHKEwec7+mQna9O/p 20 | F4rY/VVmh+4YJUzuuKMb2ZLHsZ3LJv/WBL9Ps+sRFHUN5lDfV00wAsfzEW+dxyh1 21 | kkqXwTk70r8m5m+nCdf0z+giAU7XWRkbJV2HTatSgY1ozOYARe4v0MGyLwp74I6R 22 | lrWPY97C9k4emF7WP2mglcBu+Eg2Q6A0Y3OgEiGnqkgRJEnrfpHa4wXM1sEUf4MV 23 | 5FQgyroZg45c375okr/RLP/pC4/x8ZM6GqLv4qTEOk6qWM7hWXhPRJ1TSVgCHv19 24 | jki5AkwV4EcROpFmJzfW6V9i4swJKJvYXLr58W0vogsUc8zqII4Sl7JUKZ/oN4jQ 25 | QX138r85fLawla/R0i30njmY7fJYKRwHeshgwHg6vqKobTiPuLarwn0Arv7G7ILP 26 | RjbH/8Pi+U2l8Fm/SjHMZA6gcJteRHjTgjkxSAZ19MyA08YqahJafRUVDY9QhUJb 27 | FkHhptZRf9qRji3+Njhog6s8EGACJSEOwmngAViFVz+UUyOXY94yoHvb19meNecj 28 | ArL3604gOqX3TSSWD1Dcu4kBMwQTAQgAHRYhBDau9k0CB+fu41LUh1oW5ygb56RJ 29 | BQJZ/JVnAAoJEFoW5ygb56RJ15oH/0g4hrylc79TD9xA1vEUexyOdWniY4lwH9yI 30 | /DaFznIMsE1uxmZ0FE9VX5Ks8IFR+3P9mNDQVf9xlVhnR7N597aKtU5GrpbvtlJy 31 | CoQVtzBqYKcuLC4ZFRiB33HwZrZIxTPH27UUaj1QBz748zIMC6wvtldshjNAAeRr 32 | Jz28twPO2D7svNIaPt2+OXAuRs2yUhitcsDLBV0UlOQ8xH+hzWANyhaJAS7p0k35 33 | kyFOG+n6+2qQkGdlHHuqEzdCL3EiOiK6RrvbWNUnwiG3BdZWgs43hZZBAseX3CHu 34 | MM3vIX/Fc/kuuaCWi2ysyKf7jyi/RiVIAKuLbxAB8eHsyo2G5lA= 35 | =3DTP 36 | -----END PGP PUBLIC KEY BLOCK----- 37 | -------------------------------------------------------------------------------- /src/Constraints/ObjectConstraint.php: -------------------------------------------------------------------------------- 1 | { 11 | if ($coerce && $input is string) { 12 | $input = JsonSchema\json_decode_hack($input); 13 | } 14 | 15 | $dict_spec = TypeSpec\dict(TypeSpec\string(), TypeSpec\mixed()); 16 | 17 | // This will first attempt to assert that the incoming input is a `dict`. We 18 | // can't use `coerceType` from `DictSpec` here because given a dict-like 19 | // PHP array, it would successfully coerce it into a dict. For JSON 20 | // schema, that should be considered invalid: passing an array when we 21 | // expect an object is an invalid input. 22 | try { 23 | if ($input is dict<_, _>) { 24 | return $dict_spec->assertType($input); 25 | } else { 26 | $darray_spec = TypeSpec\darray(TypeSpec\string(), TypeSpec\mixed()); 27 | // Fallback to checking legacy PHP dict-like-arrays 28 | return dict($darray_spec->assertType($input)); 29 | } 30 | } catch (TypeAssert\IncorrectTypeException $e) { 31 | self::throwInvalidInput($pointer); 32 | // Do not remove this catch until TypeAssert 3.6.2 is a requirement. 33 | // TypeSpec\dict(...)->assertType(...) throws an TypeCoersionException under 3.6.1 and below. 34 | // This is should have been an IncorrectTypeException and this has since been rectified. 35 | // This library builds against 3.6.1- and 3.6.2+, so both exceptions need to be caught. 36 | // In future, remove this second catch and inline the body of throwInvalidInput here. 37 | } catch (TypeAssert\TypeCoercionException $e) { 38 | self::throwInvalidInput($pointer); 39 | } 40 | } 41 | 42 | private static function throwInvalidInput(string $pointer): noreturn { 43 | $error = shape( 44 | 'code' => JsonSchema\FieldErrorCode::INVALID_TYPE, 45 | 'message' => 'must provide an object', 46 | ); 47 | throw new JsonSchema\InvalidFieldException($pointer, vec[$error]); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /tests/examples/ref-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "local-reference": { 5 | "$ref": "#/definitions/local-object-reference" 6 | }, 7 | "string-reference": { 8 | "$ref": "string-schema.json#/definitions/simple" 9 | }, 10 | "remote-reference": { 11 | "$ref": "#/definitions/remote-reference" 12 | }, 13 | "remote-reference-self": { 14 | "$ref": "../external_examples/ref-schema.json#/definitions/self" 15 | }, 16 | "remote-same-dir-reference": { 17 | "$ref": "string-schema.json#/definitions/coerce" 18 | }, 19 | "remote-nested-dir-reference": { 20 | "$ref": "nested/nested-definitions.json#/definitions/string" 21 | }, 22 | "nullable-unique-ref": { 23 | "anyOf": [ 24 | { 25 | "type": "null" 26 | }, 27 | { 28 | "$ref": "../external_examples/ref-schema.json#/definitions/object" 29 | } 30 | ] 31 | }, 32 | "duplicate-refs": { 33 | "type": "object", 34 | "properties": { 35 | "first": { 36 | "$ref": "../external_examples/ref-schema.json#/definitions/object" 37 | }, 38 | "second": { 39 | "$ref": "../external_examples/ref-schema.json#/definitions/object" 40 | }, 41 | "third": { 42 | "$ref": "../external_examples/ref-schema.json#/definitions/object" 43 | }, 44 | "fourth": { 45 | "$ref": "../external_examples/ref-schema.json#/definitions/object" 46 | }, 47 | "fifth": { 48 | "$ref": "../external_examples/ref-schema.json#/definitions/object" 49 | } 50 | } 51 | }, 52 | "single-item-array-ref": { 53 | "type": "array", 54 | "items": { 55 | "$ref": "../external_examples/ref-schema.json#/definitions/object" 56 | } 57 | } 58 | }, 59 | "definitions": { 60 | "remote-reference": { 61 | "$ref": "../external_examples/ref-schema.json#/definitions/string" 62 | }, 63 | "local-object-reference": { 64 | "type": "object", 65 | "properties": { 66 | "first": { 67 | "$ref": "#/definitions/local-property-reference" 68 | } 69 | } 70 | }, 71 | "local-property-reference": { 72 | "type": "string" 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Exceptions.php: -------------------------------------------------------------------------------- 1 | FieldErrorCode, 36 | 'message' => string, 37 | ?'pointer' => string, 38 | ?'constraint' => shape( 39 | 'type' => FieldErrorConstraint, 40 | ?'expected' => mixed, 41 | ?'got' => mixed, 42 | ), 43 | ?'field' => string, 44 | ); 45 | 46 | class CircularReferenceException extends \Exception {} 47 | 48 | class InvalidFieldException extends \Exception { 49 | public vec $errors; 50 | 51 | public function __construct( 52 | public string $pointer, 53 | vec $errors, 54 | int $code = 0, 55 | ?\Exception $previous = null, 56 | ) { 57 | 58 | // NB: If we haven't set `pointer` for the errors, set it now. If it is already 59 | // set it means we're bubbling the errors up and don't want to override the 60 | // more specific pointer for nested items. 61 | $errors = Vec\map( 62 | $errors, 63 | $error ==> { 64 | $error_pointer = $error['pointer'] ?? null; 65 | if ($error_pointer === null) { 66 | $error['pointer'] = $pointer; 67 | } 68 | return $error; 69 | }, 70 | ); 71 | $this->errors = $errors; 72 | 73 | $formatted_errors = Vec\map($errors, $error ==> $error['message']) 74 | |> Str\join($$, ''); 75 | $message = "Error validating field '{$pointer}': {$formatted_errors}"; 76 | parent::__construct($message, $code, $previous); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/examples/codegen/AnyOfValidatorStrings.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TAnyOfValidatorStrings = string; 15 | 16 | final class AnyOfValidatorStringsAnyOf1 { 17 | 18 | private static int $minLength = 12; 19 | private static bool $coerce = false; 20 | 21 | public static function check(mixed $input, string $pointer): string { 22 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 23 | 24 | $length = \mb_strlen($typed); 25 | Constraints\StringMinLengthConstraint::check( 26 | $length, 27 | self::$minLength, 28 | $pointer, 29 | ); 30 | return $typed; 31 | } 32 | } 33 | 34 | final class AnyOfValidatorStrings 35 | extends JsonSchema\BaseValidator { 36 | 37 | public static function check( 38 | mixed $input, 39 | string $pointer, 40 | ): TAnyOfValidatorStrings { 41 | $constraints = vec[ 42 | ExamplesStringSchemaDefinitionsMaxLength::check<>, 43 | AnyOfValidatorStringsAnyOf1::check<>, 44 | ]; 45 | $errors = vec[ 46 | ]; 47 | 48 | foreach ($constraints as $constraint) { 49 | try { 50 | $output = $constraint($input, $pointer); 51 | return $output; 52 | } catch (JsonSchema\InvalidFieldException $e) { 53 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 54 | } 55 | } 56 | 57 | $error = shape( 58 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 59 | 'constraint' => shape( 60 | 'type' => JsonSchema\FieldErrorConstraint::ANY_OF, 61 | ), 62 | 'message' => 'failed to match any allowed schemas', 63 | ); 64 | 65 | $output_errors = vec[$error]; 66 | $output_errors = \HH\Lib\Vec\concat($output_errors, $errors); 67 | throw new JsonSchema\InvalidFieldException($pointer, $output_errors); 68 | } 69 | 70 | <<__Override>> 71 | protected function process(): TAnyOfValidatorStrings { 72 | return self::check($this->input, $this->pointer); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/examples/codegen/AnyOfValidator2.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TAnyOfValidator2 = nonnull; 15 | 16 | final class AnyOfValidator2AnyOf0 { 17 | 18 | private static bool $coerce = false; 19 | 20 | public static function check(mixed $input, string $pointer): string { 21 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 22 | 23 | return $typed; 24 | } 25 | } 26 | 27 | final class AnyOfValidator2AnyOf1 { 28 | 29 | private static bool $coerce = false; 30 | 31 | public static function check(mixed $input, string $pointer): num { 32 | $typed = Constraints\NumberConstraint::check($input, $pointer, self::$coerce); 33 | 34 | return $typed; 35 | } 36 | } 37 | 38 | final class AnyOfValidator2 extends JsonSchema\BaseValidator { 39 | 40 | public static function check( 41 | mixed $input, 42 | string $pointer, 43 | ): TAnyOfValidator2 { 44 | $constraints = vec[ 45 | AnyOfValidator2AnyOf0::check<>, 46 | AnyOfValidator2AnyOf1::check<>, 47 | ]; 48 | $errors = vec[ 49 | ]; 50 | 51 | foreach ($constraints as $constraint) { 52 | try { 53 | $output = $constraint($input, $pointer); 54 | return $output; 55 | } catch (JsonSchema\InvalidFieldException $e) { 56 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 57 | } 58 | } 59 | 60 | $error = shape( 61 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 62 | 'constraint' => shape( 63 | 'type' => JsonSchema\FieldErrorConstraint::ANY_OF, 64 | ), 65 | 'message' => 'failed to match any allowed schemas', 66 | ); 67 | 68 | $output_errors = vec[$error]; 69 | $output_errors = \HH\Lib\Vec\concat($output_errors, $errors); 70 | throw new JsonSchema\InvalidFieldException($pointer, $output_errors); 71 | } 72 | 73 | <<__Override>> 74 | protected function process(): TAnyOfValidator2 { 75 | return self::check($this->input, $this->pointer); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /tests/BooleanSchemaValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 13 | public static async function beforeFirstTestAsync(): Awaitable { 14 | $ret = self::getBuilder('boolean-schema.json', 'BooleanSchemaValidator'); 15 | $ret['codegen']->build(); 16 | require_once($ret['path']); 17 | } 18 | 19 | public function testSimpleValid(): void { 20 | $validator = new BooleanSchemaValidator(dict[ 21 | 'simple' => false, 22 | ]); 23 | 24 | $validator->validate(); 25 | expect($validator->isValid())->toBeTrue(); 26 | } 27 | 28 | public function testSimpleInvalid(): void { 29 | $validator = new BooleanSchemaValidator(dict[ 30 | 'simple' => 'false', 31 | ]); 32 | 33 | $validator->validate(); 34 | expect($validator->isValid())->toBeFalse(); 35 | } 36 | 37 | public function testCoerceValid(): void { 38 | $cases = vec[ 39 | shape( 40 | 'input' => '0', 41 | 'output' => false, 42 | ), 43 | shape( 44 | 'input' => '1', 45 | 'output' => true, 46 | ), 47 | shape( 48 | 'input' => 'yes', 49 | 'output' => true, 50 | ), 51 | shape( 52 | 'input' => true, 53 | 'output' => true, 54 | ), 55 | shape( 56 | 'input' => false, 57 | 'output' => false, 58 | ), 59 | shape('input' => 0, 'output' => false), 60 | shape('input' => 1, 'output' => true), 61 | ]; 62 | 63 | foreach ($cases as $case) { 64 | $validator = new BooleanSchemaValidator(dict[ 65 | 'coerce' => $case['input'], 66 | ]); 67 | $validator->validate(); 68 | 69 | expect($validator->isValid())->toBeTrue(Str\format("should be valid for input: '%s'", (string)$case['input'])); 70 | 71 | $validated = $validator->getValidatedInput(); 72 | expect($validated)->toNotBeNull('should be valid'); 73 | 74 | expect($validated['coerce'] ?? null)->toBeSame( 75 | $case['output'], 76 | Str\format('should equal output for input: %s', (string)$case['input']), 77 | ); 78 | } 79 | 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /tests/examples/codegen/IgnoreRefsValidator.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TIgnoreRefsValidatorPropertiesRandomprop = mixed; 15 | 16 | type TIgnoreRefsValidator = shape( 17 | ?'randomprop' => TIgnoreRefsValidatorPropertiesRandomprop, 18 | ... 19 | ); 20 | 21 | final class IgnoreRefsValidatorPropertiesRandomprop { 22 | 23 | public static function check( 24 | mixed $input, 25 | string $_pointer, 26 | ): TIgnoreRefsValidatorPropertiesRandomprop { 27 | return $input; 28 | } 29 | } 30 | 31 | final class IgnoreRefsValidator 32 | extends JsonSchema\BaseValidator { 33 | 34 | private static bool $coerce = false; 35 | 36 | public static function check( 37 | mixed $input, 38 | string $pointer, 39 | ): TIgnoreRefsValidator { 40 | $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); 41 | 42 | $errors = vec[]; 43 | $output = shape(); 44 | 45 | /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ 46 | foreach ($typed as $key => $value) { 47 | /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ 48 | $output[$key] = $value; 49 | } 50 | 51 | if (\HH\Lib\C\contains_key($typed, 'randomprop')) { 52 | try { 53 | $output['randomprop'] = IgnoreRefsValidatorPropertiesRandomprop::check( 54 | $typed['randomprop'], 55 | JsonSchema\get_pointer($pointer, 'randomprop'), 56 | ); 57 | } catch (JsonSchema\InvalidFieldException $e) { 58 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 59 | } 60 | } 61 | 62 | if (\HH\Lib\C\count($errors)) { 63 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 64 | } 65 | 66 | /* HH_IGNORE_ERROR[4163] */ 67 | return $output; 68 | } 69 | 70 | <<__Override>> 71 | protected function process(): TIgnoreRefsValidator { 72 | return self::check($this->input, $this->pointer); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /tests/examples/person-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-04/schema#", 3 | "title": "Person", 4 | "type": "object", 5 | "properties": { 6 | "first_name": { 7 | "type": "string" 8 | }, 9 | "last_name": { 10 | "type": "string" 11 | }, 12 | "age": { 13 | "description": "Age in years", 14 | "type": "integer", 15 | "minimum": 0 16 | }, 17 | "string_or_num": { 18 | "type": ["string", "integer"] 19 | }, 20 | "friends": { 21 | "type": "array", 22 | "items": { 23 | "type": "object", 24 | "properties": { 25 | "first_name": { 26 | "type": "string" 27 | }, 28 | "last_name": { 29 | "type": "string" 30 | }, 31 | "age": { 32 | "type": "integer", 33 | "minimum": 0 34 | } 35 | }, 36 | "required": ["first_name", "last_name"], 37 | "additionalProperties": false 38 | } 39 | }, 40 | "string_and_number": { 41 | "type": "array", 42 | "items": [ 43 | { 44 | "type": "string" 45 | }, 46 | { 47 | "type": "number" 48 | } 49 | ] 50 | }, 51 | "devices": { 52 | "type": "array", 53 | "items": { 54 | "anyOf": [ 55 | { "$ref": "#/definitions/devices/phone" }, 56 | { 57 | "$ref": "#/definitions/devices/computer" 58 | } 59 | ] 60 | } 61 | } 62 | }, 63 | "required": ["first_name", "last_name"], 64 | "definitions": { 65 | "devices": { 66 | "phone": { 67 | "type": "object", 68 | "required": ["model", "carrier", "type"], 69 | "properties": { 70 | "type": { "type": "string", "enum": ["phone"] }, 71 | "model": { "type": "string" }, 72 | "carrier": { "type": "string" }, 73 | "number": { 74 | "type": "string", 75 | "pattern": "^(\\([0-9]{3}\\))?[0-9]{3}-[0-9]{4}$" 76 | } 77 | }, 78 | "additionalProperties": { "type": "string" } 79 | }, 80 | "computer": { 81 | "type": "object", 82 | "required": ["manufacturer", "type"], 83 | "properties": { 84 | "type": { "type": "string", "enum": ["computer"] }, 85 | "manufacturer": { "type": "string" }, 86 | "year": { "type": "integer" } 87 | }, 88 | "additionalProperties": true 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /tests/examples/codegen/GeneratedHackEnumSchemaValidator.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TGeneratedHackEnumSchemaValidator = shape( 15 | ?'enum_string' => myCoolTestEnum, 16 | ... 17 | ); 18 | 19 | 20 | enum myCoolTestEnum : string as string { 21 | ONE = 'one'; 22 | TWO = 'two'; 23 | THREE = 'three'; 24 | } 25 | 26 | final class GeneratedHackEnumSchemaValidatorPropertiesEnumString { 27 | 28 | private static bool $coerce = false; 29 | 30 | public static function check(mixed $input, string $pointer): myCoolTestEnum { 31 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 32 | 33 | $typed = Constraints\HackEnumConstraint::check( 34 | $typed, 35 | myCoolTestEnum::class, 36 | $pointer, 37 | ); 38 | return $typed; 39 | } 40 | } 41 | 42 | final class GeneratedHackEnumSchemaValidator 43 | extends JsonSchema\BaseValidator { 44 | 45 | private static bool $coerce = false; 46 | 47 | public static function check( 48 | mixed $input, 49 | string $pointer, 50 | ): TGeneratedHackEnumSchemaValidator { 51 | $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); 52 | 53 | $errors = vec[]; 54 | $output = shape(); 55 | 56 | /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ 57 | foreach ($typed as $key => $value) { 58 | /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ 59 | $output[$key] = $value; 60 | } 61 | 62 | if (\HH\Lib\C\contains_key($typed, 'enum_string')) { 63 | try { 64 | $output['enum_string'] = GeneratedHackEnumSchemaValidatorPropertiesEnumString::check( 65 | $typed['enum_string'], 66 | JsonSchema\get_pointer($pointer, 'enum_string'), 67 | ); 68 | } catch (JsonSchema\InvalidFieldException $e) { 69 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 70 | } 71 | } 72 | 73 | if (\HH\Lib\C\count($errors)) { 74 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 75 | } 76 | 77 | /* HH_IGNORE_ERROR[4163] */ 78 | return $output; 79 | } 80 | 81 | <<__Override>> 82 | protected function process(): TGeneratedHackEnumSchemaValidator { 83 | return self::check($this->input, $this->pointer); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /tests/DefaultsSchemaValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 11 | public static async function beforeFirstTestAsync(): Awaitable { 12 | $ret = 13 | self::getBuilder('defaults-schema.json', 'DefaultsSchemaValidator', shape('defaults' => shape('coerce' => true))); 14 | $ret['codegen']->build(); 15 | require_once($ret['path']); 16 | } 17 | 18 | public function testBoolean(): void { 19 | $cases = vec[ 20 | shape( 21 | 'input' => darray['boolean' => true], 22 | 'output' => darray['boolean' => true], 23 | 'valid' => true, 24 | ), 25 | shape( 26 | 'input' => darray['boolean' => 0], 27 | 'output' => darray['boolean' => false], 28 | 'valid' => true, 29 | ), 30 | shape( 31 | 'input' => darray['boolean' => 'false'], 32 | 'output' => darray['boolean' => false], 33 | 'valid' => true, 34 | ), 35 | shape( 36 | 'input' => darray['boolean' => 'true'], 37 | 'output' => darray['boolean' => true], 38 | 'valid' => true, 39 | ), 40 | ]; 41 | 42 | $this->expectCases($cases, $input ==> new DefaultsSchemaValidator($input)); 43 | } 44 | 45 | public function testBooleanCoerceFalse(): void { 46 | $cases = vec[ 47 | shape( 48 | 'input' => darray['boolean_coerce_false' => true], 49 | 'output' => darray['boolean_coerce_false' => true], 50 | 'valid' => true, 51 | ), 52 | shape( 53 | 'input' => darray['boolean_coerce_false' => 0], 54 | 'valid' => false, 55 | ), 56 | shape( 57 | 'input' => darray['boolean_coerce_false' => 'false'], 58 | 'valid' => false, 59 | ), 60 | shape( 61 | 'input' => darray['boolean_coerce_false' => 'true'], 62 | 'valid' => false, 63 | ), 64 | ]; 65 | 66 | $this->expectCases($cases, $input ==> new DefaultsSchemaValidator($input)); 67 | } 68 | 69 | public function testNestedCoerceFalse(): void { 70 | // @TODO This testcase does nothing. $cases is an unused variable. 71 | $_cases = vec[ 72 | shape( 73 | 'input' => darray[ 74 | 'nested_coerce_false' => darray['boolean_prop' => 'false', 'number_prop' => 3], 75 | ], 76 | 'output' => darray[ 77 | 'nested_coerce_false' => darray['boolean_prop' => false, 'number_prop' => 3], 78 | ], 79 | 'valid' => true, 80 | ), 81 | shape( 82 | 'input' => darray[ 83 | 'nested_coerce_false' => darray['boolean_prop' => true, 'number_prop' => '3'], 84 | ], 85 | 'valid' => false, 86 | ), 87 | ]; 88 | } 89 | 90 | } 91 | -------------------------------------------------------------------------------- /tests/DiscardAddititionalPropertiesValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 12 | public static async function beforeFirstTestAsync(): Awaitable { 13 | $ret = self::getBuilder('discard-additional-properties-schema.json', 'DiscardAddititionalPropertiesValidator'); 14 | $ret['codegen']->build(); 15 | require_once($ret['path']); 16 | } 17 | 18 | public function testDoesNotRemoveAllowedAdditionalProperties(): void { 19 | $validator = new DiscardAddititionalPropertiesValidator(dict[ 20 | 'only_additional_properties' => dict[ 21 | 'value' => 'string', 22 | ], 23 | ]); 24 | 25 | $validator->validate(); 26 | expect($validator->isValid())->toBeTrue(); 27 | 28 | $output = $validator->getValidatedInput(); 29 | $result = $output['only_additional_properties'] ?? null as nonnull; 30 | expect($result)->toContainKey('value'); 31 | 32 | } 33 | 34 | public function testRemovesNotAllowedAdditionalProperties(): void { 35 | $validator = new DiscardAddititionalPropertiesValidator(dict[ 36 | 'only_properties' => dict[ 37 | 'required_string' => 'This is required', 38 | 'number' => 3, 39 | 'additional' => 'this field is not defined the json spec', 40 | ], 41 | ]); 42 | 43 | $validator->validate(); 44 | expect($validator->isValid())->toBeTrue(); 45 | 46 | // Convert the return shape to a dictionary to do assertions without running 47 | // into the type-checker saying "no, you can't do that!" 48 | $res = $validator->getValidatedInput()['only_properties'] ?? null as nonnull; 49 | $res = Shapes::toDict($res); 50 | 51 | expect($res)->toContainKey('required_string'); 52 | expect($res)->toNotContainKey('additional'); 53 | expect($res['number'])->toBeSame(3); 54 | } 55 | 56 | public function testAdditionalProperitesRef(): void { 57 | $input = dict[ 58 | 'additional_properties_ref' => dict[ 59 | 'something' => dict[ 60 | 'something-else' => varray['array', 'of', 'strings'], 61 | ], 62 | ], 63 | ]; 64 | 65 | $validator = new DiscardAddititionalPropertiesValidator($input); 66 | $validator->validate(); 67 | expect($validator->isValid())->toBeTrue(); 68 | 69 | $input = dict[ 70 | 'additional_properties_ref' => dict[ 71 | 'something' => dict[ 72 | 'something-else' => varray[34, 'of', 'strings'], 73 | ], 74 | ], 75 | ]; 76 | 77 | $validator = new DiscardAddititionalPropertiesValidator($input); 78 | $validator->validate(); 79 | expect($validator->isValid())->toBeFalse(); 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/Codegen/Typing/Type.hack: -------------------------------------------------------------------------------- 1 | namespace Slack\Hack\JsonSchema\Codegen\Typing; 2 | 3 | use namespace HH\Lib\{C, Vec}; 4 | 5 | /** 6 | * A type in the type system. Can be a concrete type, an optional type 7 | * (which wraps a concrete type or an alias, for example converting "nothing" 8 | * to "null"), or an alias (which may wrap either an optional or concrete type). 9 | */ 10 | abstract class Type { 11 | const type TShapeFields = dict bool, 'type' => Type)>; 12 | 13 | /** 14 | * The builtin underlying this type. 15 | */ 16 | abstract public function getConcreteTypeName(): ConcreteTypeName; 17 | 18 | /** 19 | * Any generics wrapped by this type. 20 | */ 21 | abstract public function getGenerics(): vec; 22 | 23 | /** 24 | * Get the name of this type. 25 | */ 26 | abstract public function getName(): string; 27 | 28 | /** 29 | * Get the shape fields present in this type, if any. 30 | */ 31 | abstract public function getShapeFields(): this::TShapeFields; 32 | 33 | /** 34 | * Whether this type has an alias (e.g., is of form or ?). 35 | */ 36 | abstract public function hasAlias(): bool; 37 | 38 | /** 39 | * Whether this type is a closed shape. 40 | */ 41 | abstract public function isClosedShape(): bool; 42 | 43 | /** 44 | * Whether this type is optional (i.e., null can be substituted). 45 | */ 46 | abstract public function isOptional(): bool; 47 | 48 | /** 49 | * True IFF two types can be substituted for one another. 50 | */ 51 | final public function isEquivalent(Type $type): bool { 52 | if ($this->isOptional() !== $type->isOptional()) { 53 | return false; 54 | } 55 | if ($this->getConcreteTypeName() !== $type->getConcreteTypeName()) { 56 | return false; 57 | } 58 | $this_generics = $this->getGenerics(); 59 | $that_generics = $type->getGenerics(); 60 | if (C\count($this_generics) !== C\count($that_generics)) { 61 | return false; 62 | } 63 | foreach (Vec\zip($this_generics, $that_generics) as list($a, $b)) { 64 | if (!$a->isEquivalent($b)) { 65 | return false; 66 | } 67 | } 68 | $this_shape_fields = $this->getShapeFields(); 69 | $that_shape_fields = $type->getShapeFields(); 70 | if (C\count($this_shape_fields) !== C\count($that_shape_fields)) { 71 | return false; 72 | } 73 | foreach ($this_shape_fields as $name => $this_shape_field) { 74 | $that_shape_field = $that_shape_fields[$name] ?? null; 75 | if ( 76 | $that_shape_field is null || 77 | Shapes::idx($this_shape_field, 'required', false) !== Shapes::idx($that_shape_field, 'required', false) || 78 | !$this_shape_field['type']->isEquivalent($that_shape_field['type']) 79 | ) { 80 | return false; 81 | } 82 | } 83 | if ($this->isClosedShape() !== $type->isClosedShape()) { 84 | return false; 85 | } 86 | return true; 87 | } 88 | } -------------------------------------------------------------------------------- /tests/examples/codegen/AnyOfValidatorNullableArraykey.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TAnyOfValidatorNullableArraykey = ?arraykey; 15 | 16 | final class AnyOfValidatorNullableArraykeyAnyOf0 { 17 | 18 | private static int $maxLength = 10; 19 | private static bool $coerce = false; 20 | 21 | public static function check(mixed $input, string $pointer): string { 22 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 23 | 24 | $length = \mb_strlen($typed); 25 | Constraints\StringMaxLengthConstraint::check( 26 | $length, 27 | self::$maxLength, 28 | $pointer, 29 | ); 30 | return $typed; 31 | } 32 | } 33 | 34 | final class AnyOfValidatorNullableArraykeyAnyOf1 { 35 | 36 | private static int $maximum = 10; 37 | private static bool $coerce = false; 38 | 39 | public static function check(mixed $input, string $pointer): int { 40 | $typed = 41 | Constraints\IntegerConstraint::check($input, $pointer, self::$coerce); 42 | 43 | Constraints\NumberMaximumConstraint::check( 44 | $typed, 45 | self::$maximum, 46 | $pointer, 47 | ); 48 | return $typed; 49 | } 50 | } 51 | 52 | final class AnyOfValidatorNullableArraykey 53 | extends JsonSchema\BaseValidator { 54 | 55 | public static function check( 56 | mixed $input, 57 | string $pointer, 58 | ): TAnyOfValidatorNullableArraykey { 59 | if ($input === null) { 60 | return null; 61 | } 62 | 63 | $constraints = vec[ 64 | AnyOfValidatorNullableArraykeyAnyOf0::check<>, 65 | AnyOfValidatorNullableArraykeyAnyOf1::check<>, 66 | ]; 67 | $errors = vec[ 68 | ]; 69 | 70 | foreach ($constraints as $constraint) { 71 | try { 72 | $output = $constraint($input, $pointer); 73 | return $output; 74 | } catch (JsonSchema\InvalidFieldException $e) { 75 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 76 | } 77 | } 78 | 79 | $error = shape( 80 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 81 | 'constraint' => shape( 82 | 'type' => JsonSchema\FieldErrorConstraint::ANY_OF, 83 | ), 84 | 'message' => 'failed to match any allowed schemas', 85 | ); 86 | 87 | $output_errors = vec[$error]; 88 | $output_errors = \HH\Lib\Vec\concat($output_errors, $errors); 89 | throw new JsonSchema\InvalidFieldException($pointer, $output_errors); 90 | } 91 | 92 | <<__Override>> 93 | protected function process(): TAnyOfValidatorNullableArraykey { 94 | return self::check($this->input, $this->pointer); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/examples/codegen/AnyOfValidatorNullableStrings.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TAnyOfValidatorNullableStrings = ?string; 15 | 16 | final class AnyOfValidatorNullableStringsAnyOf0 { 17 | 18 | private static int $maxLength = 10; 19 | private static bool $coerce = false; 20 | 21 | public static function check(mixed $input, string $pointer): string { 22 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 23 | 24 | $length = \mb_strlen($typed); 25 | Constraints\StringMaxLengthConstraint::check( 26 | $length, 27 | self::$maxLength, 28 | $pointer, 29 | ); 30 | return $typed; 31 | } 32 | } 33 | 34 | final class AnyOfValidatorNullableStringsAnyOf1 { 35 | 36 | private static int $minLength = 12; 37 | private static bool $coerce = false; 38 | 39 | public static function check(mixed $input, string $pointer): string { 40 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 41 | 42 | $length = \mb_strlen($typed); 43 | Constraints\StringMinLengthConstraint::check( 44 | $length, 45 | self::$minLength, 46 | $pointer, 47 | ); 48 | return $typed; 49 | } 50 | } 51 | 52 | final class AnyOfValidatorNullableStrings 53 | extends JsonSchema\BaseValidator { 54 | 55 | public static function check( 56 | mixed $input, 57 | string $pointer, 58 | ): TAnyOfValidatorNullableStrings { 59 | if ($input === null) { 60 | return null; 61 | } 62 | 63 | $constraints = vec[ 64 | AnyOfValidatorNullableStringsAnyOf0::check<>, 65 | AnyOfValidatorNullableStringsAnyOf1::check<>, 66 | ]; 67 | $errors = vec[ 68 | ]; 69 | 70 | foreach ($constraints as $constraint) { 71 | try { 72 | $output = $constraint($input, $pointer); 73 | return $output; 74 | } catch (JsonSchema\InvalidFieldException $e) { 75 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 76 | } 77 | } 78 | 79 | $error = shape( 80 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 81 | 'constraint' => shape( 82 | 'type' => JsonSchema\FieldErrorConstraint::ANY_OF, 83 | ), 84 | 'message' => 'failed to match any allowed schemas', 85 | ); 86 | 87 | $output_errors = vec[$error]; 88 | $output_errors = \HH\Lib\Vec\concat($output_errors, $errors); 89 | throw new JsonSchema\InvalidFieldException($pointer, $output_errors); 90 | } 91 | 92 | <<__Override>> 93 | protected function process(): TAnyOfValidatorNullableStrings { 94 | return self::check($this->input, $this->pointer); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Codegen/Typing/DAG.hack: -------------------------------------------------------------------------------- 1 | namespace Slack\Hack\JsonSchema\Codegen\Typing; 2 | 3 | use namespace HH\Lib\{C, Keyset, Vec}; 4 | 5 | /** 6 | * A DAG over a set of key. Exposes efficient operations 7 | * to compute the lowest upper bound (LUB). 8 | */ 9 | final class DAG { 10 | private keyset $vertices = keyset[]; 11 | private dict> $children = dict[]; 12 | private dict> $parents = dict[]; 13 | 14 | /** 15 | * Instantiate the DAG from an adjacency list in which keys are parent vertices 16 | * and values are lists of their children. 17 | */ 18 | public static function fromAdjacencyList(dict> $adjacency_list): this { 19 | $dag = new self(); 20 | foreach ($adjacency_list as $parent => $children) { 21 | $dag->vertices[] = $parent; 22 | foreach ($children as $child) { 23 | $dag->vertices[] = $child; 24 | 25 | $dag->children[$parent] ??= keyset[]; 26 | $dag->children[$parent][] = $child; 27 | 28 | $dag->parents[$child] ??= keyset[]; 29 | $dag->parents[$child][] = $parent; 30 | } 31 | } 32 | return $dag; 33 | } 34 | 35 | /** 36 | * Compute the lowest upper bound of a set of vertices. 37 | * 38 | * The lowest upper bound is the vertex which is a parent of all vertices 39 | * in the set and has more ancestors than any other vertex which is a 40 | * parent of all vertices in the set. 41 | * 42 | * Returns null if the set of vertices is empty. 43 | */ 44 | public function computeLowestUpperBound(keyset $vertices): ?T { 45 | return Vec\map($vertices, $vertex ==> $this->getAncestorsWithSelf($vertex)) 46 | // This hacky-looking syntax takes the intersection of all ancestors identified by the above map. 47 | |> Keyset\intersect(($$[0] ?? keyset[]), ($$[1] ?? $$[0] ?? keyset[]), ...Vec\drop($$, 2)) 48 | |> Vec\sort_by($$, $vertex ==> $this->getNumAncestors($vertex)) 49 | |> C\last($$); 50 | } 51 | 52 | /** 53 | * Get all parents of the given vertex. 54 | */ 55 | private function getParents(T $vertex): keyset { 56 | return $this->parents[$vertex] ?? keyset[]; 57 | } 58 | 59 | /** 60 | * Get all ancestors of the given vertex. 61 | */ 62 | private function getAncestors(T $vertex): keyset { 63 | $ancestors = keyset[]; 64 | foreach ($this->getParents($vertex) as $parent) { 65 | $ancestors[] = $parent; 66 | $ancestors = Keyset\union($ancestors, $this->getAncestors($parent)); 67 | } 68 | return $ancestors; 69 | } 70 | 71 | /** 72 | * Get all ancestors of the given vertex and include the vertex as well. 73 | */ 74 | private function getAncestorsWithSelf(T $vertex): keyset { 75 | $ancestors = $this->getAncestors($vertex); 76 | $ancestors[] = $vertex; 77 | return $ancestors; 78 | } 79 | 80 | /** 81 | * Count the number of ancestors the vertex has. 82 | */ 83 | private function getNumAncestors(T $vertex): int { 84 | return C\count($this->getAncestors($vertex)); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /tests/examples/codegen/BooleanSchemaValidator.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TBooleanSchemaValidator = shape( 15 | ?'simple' => bool, 16 | ?'coerce' => bool, 17 | ... 18 | ); 19 | 20 | final class BooleanSchemaValidatorPropertiesSimple { 21 | 22 | private static bool $coerce = false; 23 | 24 | public static function check(mixed $input, string $pointer): bool { 25 | $typed = 26 | Constraints\BooleanConstraint::check($input, $pointer, self::$coerce); 27 | return $typed; 28 | } 29 | } 30 | 31 | final class BooleanSchemaValidatorPropertiesCoerce { 32 | 33 | private static bool $coerce = true; 34 | 35 | public static function check(mixed $input, string $pointer): bool { 36 | $typed = 37 | Constraints\BooleanConstraint::check($input, $pointer, self::$coerce); 38 | return $typed; 39 | } 40 | } 41 | 42 | final class BooleanSchemaValidator 43 | extends JsonSchema\BaseValidator { 44 | 45 | private static bool $coerce = false; 46 | 47 | public static function check( 48 | mixed $input, 49 | string $pointer, 50 | ): TBooleanSchemaValidator { 51 | $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); 52 | 53 | $errors = vec[]; 54 | $output = shape(); 55 | 56 | /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ 57 | foreach ($typed as $key => $value) { 58 | /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ 59 | $output[$key] = $value; 60 | } 61 | 62 | if (\HH\Lib\C\contains_key($typed, 'simple')) { 63 | try { 64 | $output['simple'] = BooleanSchemaValidatorPropertiesSimple::check( 65 | $typed['simple'], 66 | JsonSchema\get_pointer($pointer, 'simple'), 67 | ); 68 | } catch (JsonSchema\InvalidFieldException $e) { 69 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 70 | } 71 | } 72 | 73 | if (\HH\Lib\C\contains_key($typed, 'coerce')) { 74 | try { 75 | $output['coerce'] = BooleanSchemaValidatorPropertiesCoerce::check( 76 | $typed['coerce'], 77 | JsonSchema\get_pointer($pointer, 'coerce'), 78 | ); 79 | } catch (JsonSchema\InvalidFieldException $e) { 80 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 81 | } 82 | } 83 | 84 | if (\HH\Lib\C\count($errors)) { 85 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 86 | } 87 | 88 | /* HH_IGNORE_ERROR[4163] */ 89 | return $output; 90 | } 91 | 92 | <<__Override>> 93 | protected function process(): TBooleanSchemaValidator { 94 | return self::check($this->input, $this->pointer); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /tests/examples/codegen/AnyOfValidatorNullableVec.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TAnyOfValidatorNullableVec = ?vec; 15 | 16 | final class AnyOfValidatorNullableVecAnyOf1Items { 17 | 18 | private static bool $coerce = false; 19 | 20 | public static function check(mixed $input, string $pointer): string { 21 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 22 | 23 | return $typed; 24 | } 25 | } 26 | 27 | final class AnyOfValidatorNullableVecAnyOf1 { 28 | 29 | private static bool $coerce = false; 30 | 31 | public static function check(mixed $input, string $pointer): vec { 32 | $typed = Constraints\ArrayConstraint::check($input, $pointer, self::$coerce); 33 | 34 | $output = vec[]; 35 | $errors = vec[]; 36 | 37 | foreach ($typed as $index => $value) { 38 | try { 39 | $output[] = AnyOfValidatorNullableVecAnyOf1Items::check( 40 | $value, 41 | JsonSchema\get_pointer($pointer, (string) $index), 42 | ); 43 | } catch (JsonSchema\InvalidFieldException $e) { 44 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 45 | } 46 | } 47 | 48 | if (\HH\Lib\C\count($errors)) { 49 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 50 | } 51 | 52 | return $output; 53 | } 54 | } 55 | 56 | final class AnyOfValidatorNullableVec 57 | extends JsonSchema\BaseValidator { 58 | 59 | public static function check( 60 | mixed $input, 61 | string $pointer, 62 | ): TAnyOfValidatorNullableVec { 63 | if ($input === null) { 64 | return null; 65 | } 66 | 67 | $constraints = vec[ 68 | AnyOfValidatorNullableVecAnyOf1::check<>, 69 | ]; 70 | $errors = vec[ 71 | ]; 72 | 73 | foreach ($constraints as $constraint) { 74 | try { 75 | $output = $constraint($input, $pointer); 76 | return $output; 77 | } catch (JsonSchema\InvalidFieldException $e) { 78 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 79 | } 80 | } 81 | 82 | $error = shape( 83 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 84 | 'constraint' => shape( 85 | 'type' => JsonSchema\FieldErrorConstraint::ANY_OF, 86 | ), 87 | 'message' => 'failed to match any allowed schemas', 88 | ); 89 | 90 | $output_errors = vec[$error]; 91 | $output_errors = \HH\Lib\Vec\concat($output_errors, $errors); 92 | throw new JsonSchema\InvalidFieldException($pointer, $output_errors); 93 | } 94 | 95 | <<__Override>> 96 | protected function process(): TAnyOfValidatorNullableVec { 97 | return self::check($this->input, $this->pointer); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExternalExamplesRefSchemaDefinitionsObject.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TExternalExamplesRefSchemaDefinitionsObject = shape( 15 | ?'string' => string, 16 | ?'integer' => int, 17 | ... 18 | ); 19 | 20 | final class ExternalExamplesRefSchemaDefinitionsObjectPropertiesString { 21 | 22 | private static bool $coerce = false; 23 | 24 | public static function check(mixed $input, string $pointer): string { 25 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 26 | 27 | return $typed; 28 | } 29 | } 30 | 31 | final class ExternalExamplesRefSchemaDefinitionsObjectPropertiesInteger { 32 | 33 | private static bool $coerce = false; 34 | 35 | public static function check(mixed $input, string $pointer): int { 36 | $typed = 37 | Constraints\IntegerConstraint::check($input, $pointer, self::$coerce); 38 | 39 | return $typed; 40 | } 41 | } 42 | 43 | final class ExternalExamplesRefSchemaDefinitionsObject 44 | extends JsonSchema\BaseValidator { 45 | 46 | private static bool $coerce = false; 47 | 48 | public static function check( 49 | mixed $input, 50 | string $pointer, 51 | ): TExternalExamplesRefSchemaDefinitionsObject { 52 | $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); 53 | 54 | $errors = vec[]; 55 | $output = shape(); 56 | 57 | /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ 58 | foreach ($typed as $key => $value) { 59 | /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ 60 | $output[$key] = $value; 61 | } 62 | 63 | if (\HH\Lib\C\contains_key($typed, 'string')) { 64 | try { 65 | $output['string'] = ExternalExamplesRefSchemaDefinitionsObjectPropertiesString::check( 66 | $typed['string'], 67 | JsonSchema\get_pointer($pointer, 'string'), 68 | ); 69 | } catch (JsonSchema\InvalidFieldException $e) { 70 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 71 | } 72 | } 73 | 74 | if (\HH\Lib\C\contains_key($typed, 'integer')) { 75 | try { 76 | $output['integer'] = ExternalExamplesRefSchemaDefinitionsObjectPropertiesInteger::check( 77 | $typed['integer'], 78 | JsonSchema\get_pointer($pointer, 'integer'), 79 | ); 80 | } catch (JsonSchema\InvalidFieldException $e) { 81 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 82 | } 83 | } 84 | 85 | if (\HH\Lib\C\count($errors)) { 86 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 87 | } 88 | 89 | /* HH_IGNORE_ERROR[4163] */ 90 | return $output; 91 | } 92 | 93 | <<__Override>> 94 | protected function process(): TExternalExamplesRefSchemaDefinitionsObject { 95 | return self::check($this->input, $this->pointer); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /tests/MultipleOfConstraintTest.php: -------------------------------------------------------------------------------- 1 | > 12 | public static async function beforeFirstTestAsync(): Awaitable { 13 | $ret = self::getBuilder('multiple-of.json', 'MultipleOfValidator'); 14 | $ret['codegen']->build(); 15 | require_once($ret['path']); 16 | } 17 | 18 | public function provideCasesForIsValid(): dict< 19 | string, 20 | (shape(?'a_multiple_of_five_int' => int, ?'a_multiple_of_1_point_one_repeating_number' => num), bool), 21 | > { 22 | return dict[ 23 | 'zero is a multiple of 5' => tuple(shape('a_multiple_of_five_int' => 0), true), 24 | 'one is not a multiple of 5' => tuple(shape('a_multiple_of_five_int' => 1), false), 25 | 'five is a multiple of 5' => tuple(shape('a_multiple_of_five_int' => 5), true), 26 | 'fifty is a multiple of 5' => tuple(shape('a_multiple_of_five_int' => 50), true), 27 | 'zero is a mutliple of 1.1...' => tuple(shape('a_multiple_of_1_point_one_repeating_number' => 0), true), 28 | 'one is not a mutliple of 1.1...' => tuple(shape('a_multiple_of_1_point_one_repeating_number' => 1), false), 29 | '5.5... is a multiple of 1.1...' => 30 | // This will have an modulus of 1.1111111111056, testing if I can deal with it being slightly below the devidor. 31 | tuple(shape('a_multiple_of_1_point_one_repeating_number' => 5.55555555555), true), 32 | '5.5...6 is a multiple of 1.1... if you place the 6 far enough back' => 33 | // This will have an modulus of 4.4444449986969E-7, testing if I can deal with it being slightly above zero. 34 | tuple(shape('a_multiple_of_1_point_one_repeating_number' => 5.555556), true), 35 | '5555555.5... is a multiple of 1.1...' => 36 | tuple(shape('a_multiple_of_1_point_one_repeating_number' => 55555555.55555555555), true), 37 | // I arbitrarily choose to check for 6 digits. These tests need updating if we choose a different number of digits. 38 | '1.11111 <- 5 times a one behind the period is a multiple of 1.1...' => 39 | tuple(shape('a_multiple_of_1_point_one_repeating_number' => 1.11111), false), 40 | '1.111111 <- 6 times a one behind the period is a multiple of 1.1...' => 41 | tuple(shape('a_multiple_of_1_point_one_repeating_number' => 1.111111), true), 42 | ]; 43 | } 44 | 45 | <> 46 | public function testIsValid(shape(...) $value, bool $is_valid): void { 47 | $schema = new Generated\MultipleOfValidator($value); 48 | $schema->validate(); 49 | expect($schema->isValid())->toBeSame($is_valid); 50 | } 51 | 52 | public function testForError(): void { 53 | $schema = new Generated\MultipleOfValidator(shape('a_multiple_of_five_int' => 1)); 54 | $schema->validate(); 55 | $err = $schema->getErrors()[0]; 56 | expect(Shapes::idx($err, 'constraint') as nonnull['type'])->toBeSame(JsonSchema\FieldErrorConstraint::MULTIPLE_OF); 57 | expect(Shapes::idx($err, 'constraint') |> Shapes::idx($$ as nonnull, 'got'))->toBeSame(1); 58 | expect(Shapes::idx($err, 'constraint') |> Shapes::idx($$ as nonnull, 'expected'))->toBeSame(5); 59 | expect($err['message'])->toBeSame('must be a multiple of 5'); 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /tests/examples/codegen/EnumSchemaValidator.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TEnumSchemaValidator = shape( 15 | ?'enum_string' => string, 16 | ?'enum_number' => num, 17 | ... 18 | ); 19 | 20 | final class EnumSchemaValidatorPropertiesEnumString { 21 | 22 | private static vec $enum = vec[ 23 | 'one', 24 | 'two', 25 | 'three', 26 | ]; 27 | private static bool $coerce = false; 28 | 29 | public static function check(mixed $input, string $pointer): string { 30 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 31 | 32 | Constraints\EnumConstraint::check($typed, self::$enum, $pointer); 33 | return $typed; 34 | } 35 | } 36 | 37 | final class EnumSchemaValidatorPropertiesEnumNumber { 38 | 39 | private static vec $enum = vec[ 40 | 1, 41 | 2, 42 | 3, 43 | ]; 44 | private static bool $coerce = false; 45 | 46 | public static function check(mixed $input, string $pointer): num { 47 | $typed = Constraints\NumberConstraint::check($input, $pointer, self::$coerce); 48 | 49 | Constraints\EnumConstraint::check($typed, self::$enum, $pointer); 50 | return $typed; 51 | } 52 | } 53 | 54 | final class EnumSchemaValidator 55 | extends JsonSchema\BaseValidator { 56 | 57 | private static bool $coerce = false; 58 | 59 | public static function check( 60 | mixed $input, 61 | string $pointer, 62 | ): TEnumSchemaValidator { 63 | $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); 64 | 65 | $errors = vec[]; 66 | $output = shape(); 67 | 68 | /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ 69 | foreach ($typed as $key => $value) { 70 | /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ 71 | $output[$key] = $value; 72 | } 73 | 74 | if (\HH\Lib\C\contains_key($typed, 'enum_string')) { 75 | try { 76 | $output['enum_string'] = EnumSchemaValidatorPropertiesEnumString::check( 77 | $typed['enum_string'], 78 | JsonSchema\get_pointer($pointer, 'enum_string'), 79 | ); 80 | } catch (JsonSchema\InvalidFieldException $e) { 81 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 82 | } 83 | } 84 | 85 | if (\HH\Lib\C\contains_key($typed, 'enum_number')) { 86 | try { 87 | $output['enum_number'] = EnumSchemaValidatorPropertiesEnumNumber::check( 88 | $typed['enum_number'], 89 | JsonSchema\get_pointer($pointer, 'enum_number'), 90 | ); 91 | } catch (JsonSchema\InvalidFieldException $e) { 92 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 93 | } 94 | } 95 | 96 | if (\HH\Lib\C\count($errors)) { 97 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 98 | } 99 | 100 | /* HH_IGNORE_ERROR[4163] */ 101 | return $output; 102 | } 103 | 104 | <<__Override>> 105 | protected function process(): TEnumSchemaValidator { 106 | return self::check($this->input, $this->pointer); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hack JSON Schema 2 | 3 | [![Build Status](https://travis-ci.org/slackhq/hack-json-schema.svg?branch=master)](https://travis-ci.org/slackhq/hack-json-schema) 4 | 5 | Hack JSON Schema is a library for validating JSON inputs, using JSON schemas, in Hack. 6 | 7 | Given a JSON schema file, you can generate a validator in Hack to validate incoming JSON against the schema. If the JSON conforms to the schema, the validator will returned typed output. 8 | 9 | There are several benefits to generation: 10 | 11 | - We don't have to parse the JSON schema to validate the incoming object at runtime. 12 | - We can output typed shapes that are generated from the JSON schema, increasing the type safety of downstream code. 13 | 14 | ## Usage 15 | 16 | ### Codegen::forPath 17 | The most basic way to use this library is to generate a validator from a JSON schema file: 18 | 19 | ```hack 20 | use type Slack\Hack\JsonSchema\Codegen\Codegen; 21 | 22 | Codegen::forPath('/path/to/json-schema.json', shape( 23 | 'validator' => shape( 24 | 'file' => '/path/to/MyJsonSchemaValidator.php', 25 | 'class' => 'MyJsonSchemaValidator', 26 | ), 27 | ))->build(); 28 | ``` 29 | 30 | `/path/to/MyJsonSchemaValidator.php` now exists with a class: 31 | 32 | ```hack 33 | final class MyJsonSchemaValidator extends BaseValidator { 34 | ... class contents 35 | } 36 | ``` 37 | 38 | Each validator has a `validate` method, which takes a decoded JSON object: 39 | 40 | ```hack 41 | $json = json_decode($args['json_input'], true); 42 | $validator = new MyJsonSchemaValidator($json); 43 | $validator->validate(); 44 | if (!$validator->isValid()) { 45 | print_r("invalid_json", $validator->getErrors()); 46 | return; 47 | } 48 | 49 | // JSON is valid, get typed object: 50 | $validated = $validator->getValidatedInput(); 51 | ``` 52 | 53 | ### Codegen::forPaths 54 | If you have multiple JSON schemas that leverage the `$ref` attribute, you should prefer to use `Codegen::forPaths` over `Codegen::forPath`. 55 | 56 | The workflow for `Codegen::forPath` is: 57 | - Given a JSON schema, "de-reference" the schema. De-referencing is the process of resolving all of the `$ref` paths with their actual schema. This creates a single de-referenced schema. 58 | - With the de-referenced schema, generate a validator. 59 | 60 | This works well if you only have one primary schema, but if you have multiple schemas, each with common refs, you'll start to generate a lot of duplicate code. 61 | 62 | In these cases, you can use `Codegen::forPaths`. 63 | 64 | ```hack 65 | use type Slack\Hack\JsonSchema\Codegen\Codegen; 66 | 67 | $schemas = vec['/path/to/json-schema-1.json', '/path/to/json-schema-2.json', '/path/to/json-schema-3.json']; 68 | Codegen::forPaths($schemas, shape( 69 | 'validator' => shape( 70 | 'refs' => shape( 71 | 'unique' => shape( 72 | 'source_root' => '/path/to', 73 | 'output_root' => '/path/gen' 74 | ) 75 | ) 76 | ), 77 | ))->build(); 78 | ``` 79 | 80 | By defining the `source_root` and `output_root` we can generate unique validators per `$ref` we come across. We can then re-use those validators when generating other validators. 81 | 82 | ## Developing 83 | 84 | ### Installing Dependencies 85 | We handle all dependencies through Docker. It's as simple as: 86 | 87 | ```console 88 | make install 89 | ``` 90 | 91 | ### Running Tests 92 | ```console 93 | make test 94 | ``` 95 | 96 | ## Related Libraries 97 | This library was inspired by the ideas in these related libraries: 98 | 99 | - https://github.com/hhvm/hack-router-codegen 100 | - https://github.com/justinrainbow/json-schema 101 | 102 | ## License 103 | Hack JSON Schema is MIT-licensed. 104 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributors Guide 2 | 3 | Interested in contributing? Awesome! Before you do though, please read our 4 | [Code of Conduct](https://slackhq.github.io/code-of-conduct). We take it very seriously, and expect that you will as 5 | well. 6 | 7 | There are many ways you can contribute! :heart: 8 | 9 | ### Bug Reports and Fixes :bug: 10 | - If you find a bug, please search for it in the [Issues](https://github.com/slackhq/hack-json-schema/issues), and if it isn't already tracked, 11 | [create a new issue](https://github.com/slackhq/hack-json-schema/issues/new). Fill out the "Bug Report" section of the issue template. Even if an Issue is closed, feel free to comment and add details, it will still 12 | be reviewed. 13 | - Issues that have already been identified as a bug (note: able to reproduce) will be labelled `bug`. 14 | - If you'd like to submit a fix for a bug, [send a Pull Request](#creating_a_pull_request) and mention the Issue number. 15 | - Include tests that isolate the bug and verifies that it was fixed. 16 | 17 | ### New Features :bulb: 18 | - If you'd like to add new functionality to this project, describe the problem you want to solve in a [new Issue](https://github.com/slackhq/hack-json-schema/issues/new). 19 | - Issues that have been identified as a feature request will be labelled `enhancement`. 20 | - If you'd like to implement the new feature, please wait for feedback from the project 21 | maintainers before spending too much time writing the code. In some cases, `enhancement`s may 22 | not align well with the project objectives at the time. 23 | 24 | ### Tests :mag:, Documentation :books:, Miscellaneous :sparkles: 25 | - If you'd like to improve the tests, you want to make the documentation clearer, you have an 26 | alternative implementation of something that may have advantages over the way its currently 27 | done, or you have any other change, we would be happy to hear about it! 28 | - If its a trivial change, go ahead and [send a Pull Request](#creating_a_pull_request) with the changes you have in mind. 29 | - If not, [open an Issue](https://github.com/slackhq/hack-json-schema/issues/new) to discuss the idea first. 30 | 31 | If you're new to our project and looking for some way to make your first contribution, look for 32 | Issues labelled `good first contribution`. 33 | 34 | ## Requirements 35 | 36 | For your contribution to be accepted: 37 | 38 | - [x] You must have signed the [Contributor License Agreement (CLA)](https://cla-assistant.io/slackhq/hack-json-schema). 39 | - [x] The test suite must be complete and pass. 40 | - [x] The changes must be approved by code review. 41 | - [x] Commits should be atomic and messages must be descriptive. Related issues should be mentioned by Issue number. 42 | 43 | If the contribution doesn't meet the above criteria, you may fail our automated checks or a maintainer will discuss it with you. You can continue to improve a Pull Request by adding commits to the branch from which the PR was created. 44 | 45 | [Interested in knowing more about about pull requests at Slack?](https://slack.engineering/on-empathy-pull-requests-979e4257d158#.awxtvmb2z) 46 | 47 | ## Creating a Pull Request 48 | 49 | 1. :fork_and_knife: Fork the repository on GitHub. 50 | 51 | 2. :runner: Clone/fetch your fork to your local development machine. It's a good idea to run the tests just 52 | to make sure everything is in order. 53 | 3. :herb: Create a new branch and check it out. 54 | 4. :crystal_ball: Make your changes and commit them locally. Magic happens here! 55 | 5. :arrow_heading_up: Push your new branch to your fork. (e.g. `git push username fix-issue-16`). 56 | 6. :inbox_tray: Open a Pull Request on github.com from your new branch on your fork to `master` in this 57 | repository. 58 | -------------------------------------------------------------------------------- /tests/examples/codegen/ExternalExamplesFriendsSchemaDefinitionsDevicesPhone.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TExternalExamplesFriendsSchemaDefinitionsDevicesPhone = shape( 15 | 'model' => int, 16 | ?'carrier/provider' => string, 17 | ... 18 | ); 19 | 20 | final class ExternalExamplesFriendsSchemaDefinitionsDevicesPhonePropertiesModel { 21 | 22 | private static bool $coerce = false; 23 | 24 | public static function check(mixed $input, string $pointer): int { 25 | $typed = 26 | Constraints\IntegerConstraint::check($input, $pointer, self::$coerce); 27 | 28 | return $typed; 29 | } 30 | } 31 | 32 | final class ExternalExamplesFriendsSchemaDefinitionsDevicesPhonePropertiesCarrierNanProvider { 33 | 34 | private static bool $coerce = false; 35 | 36 | public static function check(mixed $input, string $pointer): string { 37 | $typed = Constraints\StringConstraint::check($input, $pointer, self::$coerce); 38 | 39 | return $typed; 40 | } 41 | } 42 | 43 | final class ExternalExamplesFriendsSchemaDefinitionsDevicesPhone 44 | extends JsonSchema\BaseValidator { 45 | 46 | private static keyset $required = keyset[ 47 | 'model', 48 | ]; 49 | private static bool $coerce = false; 50 | 51 | public static function check( 52 | mixed $input, 53 | string $pointer, 54 | ): TExternalExamplesFriendsSchemaDefinitionsDevicesPhone { 55 | $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); 56 | Constraints\ObjectRequiredConstraint::check( 57 | $typed, 58 | self::$required, 59 | $pointer, 60 | ); 61 | 62 | $errors = vec[]; 63 | $output = shape(); 64 | 65 | /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ 66 | foreach ($typed as $key => $value) { 67 | /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ 68 | $output[$key] = $value; 69 | } 70 | 71 | if (\HH\Lib\C\contains_key($typed, 'model')) { 72 | try { 73 | $output['model'] = ExternalExamplesFriendsSchemaDefinitionsDevicesPhonePropertiesModel::check( 74 | $typed['model'], 75 | JsonSchema\get_pointer($pointer, 'model'), 76 | ); 77 | } catch (JsonSchema\InvalidFieldException $e) { 78 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 79 | } 80 | } 81 | 82 | if (\HH\Lib\C\contains_key($typed, 'carrier/provider')) { 83 | try { 84 | $output['carrier/provider'] = ExternalExamplesFriendsSchemaDefinitionsDevicesPhonePropertiesCarrierNanProvider::check( 85 | $typed['carrier/provider'], 86 | JsonSchema\get_pointer($pointer, 'carrier/provider'), 87 | ); 88 | } catch (JsonSchema\InvalidFieldException $e) { 89 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 90 | } 91 | } 92 | 93 | if (\HH\Lib\C\count($errors)) { 94 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 95 | } 96 | 97 | /* HH_IGNORE_ERROR[4163] */ 98 | return $output; 99 | } 100 | 101 | <<__Override>> 102 | protected function process( 103 | ): TExternalExamplesFriendsSchemaDefinitionsDevicesPhone { 104 | return self::check($this->input, $this->pointer); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/examples/codegen/MultipleOfValidator.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TMultipleOfValidator = shape( 15 | ?'a_multiple_of_five_int' => int, 16 | ?'a_multiple_of_1_point_one_repeating_number' => num, 17 | ... 18 | ); 19 | 20 | final class MultipleOfValidatorPropertiesAMultipleOfFiveInt { 21 | 22 | private static num $devisorForMultipleOf = 5; 23 | private static bool $coerce = false; 24 | 25 | public static function check(mixed $input, string $pointer): int { 26 | $typed = 27 | Constraints\IntegerConstraint::check($input, $pointer, self::$coerce); 28 | 29 | Constraints\NumberMultipleOfConstraint::check( 30 | $typed, 31 | self::$devisorForMultipleOf, 32 | $pointer, 33 | ); 34 | return $typed; 35 | } 36 | } 37 | 38 | final class MultipleOfValidatorPropertiesAMultipleOf1PointOneRepeatingNumber { 39 | 40 | private static num $devisorForMultipleOf = 1.1111111111111; 41 | private static bool $coerce = false; 42 | 43 | public static function check(mixed $input, string $pointer): num { 44 | $typed = Constraints\NumberConstraint::check($input, $pointer, self::$coerce); 45 | 46 | Constraints\NumberMultipleOfConstraint::check( 47 | $typed, 48 | self::$devisorForMultipleOf, 49 | $pointer, 50 | ); 51 | return $typed; 52 | } 53 | } 54 | 55 | final class MultipleOfValidator 56 | extends JsonSchema\BaseValidator { 57 | 58 | private static bool $coerce = false; 59 | 60 | public static function check( 61 | mixed $input, 62 | string $pointer, 63 | ): TMultipleOfValidator { 64 | $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); 65 | 66 | $errors = vec[]; 67 | $output = shape(); 68 | 69 | /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ 70 | foreach ($typed as $key => $value) { 71 | /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ 72 | $output[$key] = $value; 73 | } 74 | 75 | if (\HH\Lib\C\contains_key($typed, 'a_multiple_of_five_int')) { 76 | try { 77 | $output['a_multiple_of_five_int'] = MultipleOfValidatorPropertiesAMultipleOfFiveInt::check( 78 | $typed['a_multiple_of_five_int'], 79 | JsonSchema\get_pointer($pointer, 'a_multiple_of_five_int'), 80 | ); 81 | } catch (JsonSchema\InvalidFieldException $e) { 82 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 83 | } 84 | } 85 | 86 | if (\HH\Lib\C\contains_key($typed, 'a_multiple_of_1_point_one_repeating_number')) { 87 | try { 88 | $output['a_multiple_of_1_point_one_repeating_number'] = MultipleOfValidatorPropertiesAMultipleOf1PointOneRepeatingNumber::check( 89 | $typed['a_multiple_of_1_point_one_repeating_number'], 90 | JsonSchema\get_pointer($pointer, 'a_multiple_of_1_point_one_repeating_number'), 91 | ); 92 | } catch (JsonSchema\InvalidFieldException $e) { 93 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 94 | } 95 | } 96 | 97 | if (\HH\Lib\C\count($errors)) { 98 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 99 | } 100 | 101 | /* HH_IGNORE_ERROR[4163] */ 102 | return $output; 103 | } 104 | 105 | <<__Override>> 106 | protected function process(): TMultipleOfValidator { 107 | return self::check($this->input, $this->pointer); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /tests/examples/codegen/AnyOfValidatorNestedNullableAnyOf.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TAnyOfValidatorNestedNullableAnyOfAnyOf1 = ?num; 15 | 16 | type TAnyOfValidatorNestedNullableAnyOf = ?num; 17 | 18 | final class AnyOfValidatorNestedNullableAnyOfAnyOf0 { 19 | 20 | private static bool $coerce = false; 21 | 22 | public static function check(mixed $input, string $pointer): int { 23 | $typed = 24 | Constraints\IntegerConstraint::check($input, $pointer, self::$coerce); 25 | 26 | return $typed; 27 | } 28 | } 29 | 30 | final class AnyOfValidatorNestedNullableAnyOfAnyOf1AnyOf1 { 31 | 32 | private static bool $coerce = false; 33 | 34 | public static function check(mixed $input, string $pointer): num { 35 | $typed = Constraints\NumberConstraint::check($input, $pointer, self::$coerce); 36 | 37 | return $typed; 38 | } 39 | } 40 | 41 | final class AnyOfValidatorNestedNullableAnyOfAnyOf1 { 42 | 43 | public static function check( 44 | mixed $input, 45 | string $pointer, 46 | ): TAnyOfValidatorNestedNullableAnyOfAnyOf1 { 47 | if ($input === null) { 48 | return null; 49 | } 50 | 51 | $constraints = vec[ 52 | AnyOfValidatorNestedNullableAnyOfAnyOf1AnyOf1::check<>, 53 | ]; 54 | $errors = vec[ 55 | ]; 56 | 57 | foreach ($constraints as $constraint) { 58 | try { 59 | $output = $constraint($input, $pointer); 60 | return $output; 61 | } catch (JsonSchema\InvalidFieldException $e) { 62 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 63 | } 64 | } 65 | 66 | $error = shape( 67 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 68 | 'constraint' => shape( 69 | 'type' => JsonSchema\FieldErrorConstraint::ANY_OF, 70 | ), 71 | 'message' => 'failed to match any allowed schemas', 72 | ); 73 | 74 | $output_errors = vec[$error]; 75 | $output_errors = \HH\Lib\Vec\concat($output_errors, $errors); 76 | throw new JsonSchema\InvalidFieldException($pointer, $output_errors); 77 | } 78 | } 79 | 80 | final class AnyOfValidatorNestedNullableAnyOf 81 | extends JsonSchema\BaseValidator { 82 | 83 | public static function check( 84 | mixed $input, 85 | string $pointer, 86 | ): TAnyOfValidatorNestedNullableAnyOf { 87 | if ($input === null) { 88 | return null; 89 | } 90 | 91 | $constraints = vec[ 92 | AnyOfValidatorNestedNullableAnyOfAnyOf0::check<>, 93 | AnyOfValidatorNestedNullableAnyOfAnyOf1::check<>, 94 | ]; 95 | $errors = vec[ 96 | ]; 97 | 98 | foreach ($constraints as $constraint) { 99 | try { 100 | $output = $constraint($input, $pointer); 101 | return $output; 102 | } catch (JsonSchema\InvalidFieldException $e) { 103 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 104 | } 105 | } 106 | 107 | $error = shape( 108 | 'code' => JsonSchema\FieldErrorCode::FAILED_CONSTRAINT, 109 | 'constraint' => shape( 110 | 'type' => JsonSchema\FieldErrorConstraint::ANY_OF, 111 | ), 112 | 'message' => 'failed to match any allowed schemas', 113 | ); 114 | 115 | $output_errors = vec[$error]; 116 | $output_errors = \HH\Lib\Vec\concat($output_errors, $errors); 117 | throw new JsonSchema\InvalidFieldException($pointer, $output_errors); 118 | } 119 | 120 | <<__Override>> 121 | protected function process(): TAnyOfValidatorNestedNullableAnyOf { 122 | return self::check($this->input, $this->pointer); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/RefSchemaValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 12 | public static async function beforeFirstTestAsync(): Awaitable { 13 | $ret = self::getBuilder( 14 | 'ref-schema.json', 15 | 'RefSchemaValidator', 16 | shape( 17 | 'refs' => shape( 18 | 'unique' => shape( 19 | 'source_root' => __DIR__, 20 | 'output_root' => __DIR__.'/examples/codegen', 21 | ), 22 | ), 23 | ), 24 | ); 25 | $ret['codegen']->build(); 26 | require_once($ret['path']); 27 | } 28 | 29 | public function testUniqueRefs(): void { 30 | $cases = vec[ 31 | shape( 32 | 'input' => darray['remote-reference' => 'one'], 33 | 'output' => darray['remote-reference' => 'one'], 34 | 'valid' => true, 35 | ), 36 | shape( 37 | 'input' => darray['remote-reference' => 5], 38 | 'valid' => false, 39 | ), 40 | shape( 41 | 'input' => darray[ 42 | 'duplicate-refs' => darray[ 43 | 'first' => darray['string' => 'test', 'integer' => 5], 44 | 'fourth' => darray['string' => 'test', 'integer' => 5], 45 | ], 46 | ], 47 | 'output' => darray[ 48 | 'duplicate-refs' => darray[ 49 | 'first' => darray['string' => 'test', 'integer' => 5], 50 | 'fourth' => darray['string' => 'test', 'integer' => 5], 51 | ], 52 | ], 53 | 'valid' => true, 54 | ), 55 | shape( 56 | 'input' => darray['remote-same-dir-reference' => 4], 57 | 'output' => darray['remote-same-dir-reference' => '4'], 58 | 'valid' => true, 59 | ), 60 | shape( 61 | 'input' => darray['remote-nested-dir-reference' => 'test'], 62 | 'output' => darray['remote-nested-dir-reference' => 'test'], 63 | 'valid' => true, 64 | ), 65 | shape( 66 | 'input' => darray[ 67 | 'single-item-array-ref' => varray[ 68 | darray['string' => 'test', 'integer' => 5], 69 | darray['string' => 'test2', 'integer' => 10], 70 | ], 71 | ], 72 | 'output' => darray[ 73 | 'single-item-array-ref' => vec[ 74 | darray['string' => 'test', 'integer' => 5], 75 | darray['string' => 'test2', 'integer' => 10], 76 | ], 77 | ], 78 | 'valid' => true, 79 | ), 80 | shape( 81 | 'input' => darray['local-reference' => darray['first' => 'test']], 82 | 'output' => darray['local-reference' => darray['first' => 'test']], 83 | 'valid' => true, 84 | ), 85 | ]; 86 | 87 | $this->expectCases($cases, $input ==> new RefSchemaValidator($input)); 88 | } 89 | 90 | public function testNullableUniqueRefNullValue(): void { 91 | $input = darray['nullable-unique-ref' => null]; 92 | 93 | $validator = new RefSchemaValidator($input); 94 | $validator->validate(); 95 | 96 | expect($validator->isValid())->toBeTrue(); 97 | $validated = $validator->getValidatedInput(); 98 | $value = $validated['nullable-unique-ref'] ?? null; 99 | expect($value)->toBeNull(); 100 | } 101 | 102 | public function testNullableUniqueRef(): void { 103 | $input = darray['nullable-unique-ref' => darray['integer' => 1, 'string' => 'string']]; 104 | 105 | $validator = new RefSchemaValidator($input); 106 | $validator->validate(); 107 | 108 | expect($validator->isValid())->toBeTrue(); 109 | $validated = $validator->getValidatedInput(); 110 | $value = $validated['nullable-unique-ref'] ?? null as nonnull; 111 | 112 | expect($value['string'] ?? '')->toBeSame('string'); 113 | expect($value['integer'] ?? 0)->toBeSame(1); 114 | } 115 | 116 | } 117 | -------------------------------------------------------------------------------- /tests/examples/codegen/GeoSchemaValidator.php: -------------------------------------------------------------------------------- 1 | > 9 | */ 10 | namespace Slack\Hack\JsonSchema\Tests\Generated; 11 | use namespace Slack\Hack\JsonSchema; 12 | use namespace Slack\Hack\JsonSchema\Constraints; 13 | 14 | type TGeoSchemaValidator = shape( 15 | 'latitude' => num, 16 | 'longitude' => num, 17 | ... 18 | ); 19 | 20 | final class GeoSchemaValidatorPropertiesLatitude { 21 | 22 | private static int $maximum = 90; 23 | private static int $minimum = -90; 24 | private static bool $coerce = false; 25 | 26 | public static function check(mixed $input, string $pointer): num { 27 | $typed = Constraints\NumberConstraint::check($input, $pointer, self::$coerce); 28 | 29 | Constraints\NumberMaximumConstraint::check( 30 | $typed, 31 | self::$maximum, 32 | $pointer, 33 | ); 34 | Constraints\NumberMinimumConstraint::check( 35 | $typed, 36 | self::$minimum, 37 | $pointer, 38 | ); 39 | return $typed; 40 | } 41 | } 42 | 43 | final class GeoSchemaValidatorPropertiesLongitude { 44 | 45 | private static int $maximum = 180; 46 | private static int $minimum = -180; 47 | private static bool $coerce = false; 48 | 49 | public static function check(mixed $input, string $pointer): num { 50 | $typed = Constraints\NumberConstraint::check($input, $pointer, self::$coerce); 51 | 52 | Constraints\NumberMaximumConstraint::check( 53 | $typed, 54 | self::$maximum, 55 | $pointer, 56 | ); 57 | Constraints\NumberMinimumConstraint::check( 58 | $typed, 59 | self::$minimum, 60 | $pointer, 61 | ); 62 | return $typed; 63 | } 64 | } 65 | 66 | final class GeoSchemaValidator 67 | extends JsonSchema\BaseValidator { 68 | 69 | private static keyset $required = keyset[ 70 | 'latitude', 71 | 'longitude', 72 | ]; 73 | private static bool $coerce = false; 74 | 75 | public static function check( 76 | mixed $input, 77 | string $pointer, 78 | ): TGeoSchemaValidator { 79 | $typed = Constraints\ObjectConstraint::check($input, $pointer, self::$coerce); 80 | Constraints\ObjectRequiredConstraint::check( 81 | $typed, 82 | self::$required, 83 | $pointer, 84 | ); 85 | 86 | $errors = vec[]; 87 | $output = shape(); 88 | 89 | /*HHAST_IGNORE_ERROR[UnusedVariable] Some loops generated with this statement do not use their $value*/ 90 | foreach ($typed as $key => $value) { 91 | /* HH_IGNORE_ERROR[4051] allow dynamic access to preserve input. See comment in the codegen lib for reasoning and alternatives if needed. */ 92 | $output[$key] = $value; 93 | } 94 | 95 | if (\HH\Lib\C\contains_key($typed, 'latitude')) { 96 | try { 97 | $output['latitude'] = GeoSchemaValidatorPropertiesLatitude::check( 98 | $typed['latitude'], 99 | JsonSchema\get_pointer($pointer, 'latitude'), 100 | ); 101 | } catch (JsonSchema\InvalidFieldException $e) { 102 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 103 | } 104 | } 105 | 106 | if (\HH\Lib\C\contains_key($typed, 'longitude')) { 107 | try { 108 | $output['longitude'] = GeoSchemaValidatorPropertiesLongitude::check( 109 | $typed['longitude'], 110 | JsonSchema\get_pointer($pointer, 'longitude'), 111 | ); 112 | } catch (JsonSchema\InvalidFieldException $e) { 113 | $errors = \HH\Lib\Vec\concat($errors, $e->errors); 114 | } 115 | } 116 | 117 | if (\HH\Lib\C\count($errors)) { 118 | throw new JsonSchema\InvalidFieldException($pointer, $errors); 119 | } 120 | 121 | /* HH_IGNORE_ERROR[4163] */ 122 | return $output; 123 | } 124 | 125 | <<__Override>> 126 | protected function process(): TGeoSchemaValidator { 127 | return self::check($this->input, $this->pointer); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /tests/NumericalSchemaValidatorTest.php: -------------------------------------------------------------------------------- 1 | > 16 | public static async function beforeFirstTestAsync(): Awaitable { 17 | $ret = self::getBuilder('numerical-schema.json', 'NumericalSchemaValidator'); 18 | $ret['codegen']->build(); 19 | require_once($ret['path']); 20 | } 21 | 22 | public function testInteger(): void { 23 | $cases = vec[ 24 | shape( 25 | 'input' => darray['integer' => 1000], 26 | 'output' => darray['integer' => 1000], 27 | 'valid' => true, 28 | ), 29 | shape( 30 | 'input' => darray['integer' => 0], 31 | 'output' => darray['integer' => 0], 32 | 'valid' => true, 33 | ), 34 | shape('input' => darray['integer' => '1000'], 'valid' => false), 35 | shape('input' => darray['integer' => 1000.00], 'valid' => false), 36 | ]; 37 | 38 | $this->expectCases($cases, $input ==> new NumericalSchemaValidator($input)); 39 | } 40 | 41 | public function testNumber(): void { 42 | $cases = vec[ 43 | shape( 44 | 'input' => darray['number' => 1000], 45 | 'output' => darray['number' => 1000], 46 | 'valid' => true, 47 | ), 48 | shape( 49 | 'input' => darray['number' => 1000.00], 50 | 'output' => darray['number' => 1000.00], 51 | 'valid' => true, 52 | ), 53 | shape('input' => darray['number' => '1000'], 'valid' => false), 54 | shape('input' => darray['number' => vec[]], 'valid' => false), 55 | ]; 56 | 57 | $this->expectCases($cases, $input ==> new NumericalSchemaValidator($input)); 58 | } 59 | 60 | public function testIntegerCoerce(): void { 61 | $cases = vec[ 62 | shape( 63 | 'input' => darray['integer_coerce' => 1], 64 | 'output' => darray['integer_coerce' => 1], 65 | 'valid' => true, 66 | ), 67 | shape( 68 | 'input' => darray['integer_coerce' => '100'], 69 | 'output' => darray['integer_coerce' => 100], 70 | 'valid' => true, 71 | ), 72 | shape( 73 | 'input' => darray['integer_coerce' => '100.00'], 74 | 'valid' => false, 75 | ), 76 | shape( 77 | 'input' => darray['integer_coerce' => 'invalid'], 78 | 'valid' => false, 79 | ), 80 | ]; 81 | 82 | $this->expectCases($cases, $input ==> new NumericalSchemaValidator($input)); 83 | } 84 | 85 | public function testNumberCoerce(): void { 86 | $cases = vec[ 87 | shape( 88 | 'input' => darray['number_coerce' => 1.0], 89 | 'output' => darray['number_coerce' => 1.0], 90 | 'valid' => true, 91 | ), 92 | shape( 93 | 'input' => darray['number_coerce' => '100'], 94 | 'output' => darray['number_coerce' => 100], 95 | 'valid' => true, 96 | ), 97 | shape( 98 | 'input' => darray['number_coerce' => '100.0'], 99 | 'output' => darray['number_coerce' => 100.0], 100 | 'valid' => true, 101 | ), 102 | shape( 103 | 'input' => darray['number_coerce' => 'invalid'], 104 | 'valid' => false, 105 | ), 106 | ]; 107 | 108 | $this->expectCases($cases, $input ==> new NumericalSchemaValidator($input)); 109 | } 110 | 111 | public function testHackEnum(): void { 112 | $cases = vec[ 113 | shape( 114 | 'input' => darray['hack_enum' => 1], 115 | 'output' => darray['hack_enum' => TestIntEnum::ABC], 116 | 'valid' => true, 117 | ), 118 | shape( 119 | 'input' => darray['hack_enum' => 2], 120 | 'output' => darray['hack_enum' => TestIntEnum::DEF], 121 | 'valid' => true, 122 | ), 123 | shape('input' => darray['hack_enum' => 0], 'valid' => false), 124 | shape('input' => darray['hack_enum' => 3], 'valid' => false), 125 | shape('input' => darray['hack_enum' => 1000], 'valid' => false), 126 | ]; 127 | 128 | $this->expectCases($cases, $input ==> new NumericalSchemaValidator($input)); 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /src/Codegen/Constraints/Context.php: -------------------------------------------------------------------------------- 1 | $schema_dict, 21 | private string $classname, 22 | private CodegenFile $file, 23 | private keyset $seen_refs = keyset[], 24 | ) { 25 | $this->validatorConfig = $codegenConfig['validator']; 26 | $this->refsConfig = $this->validatorConfig['refs'] ?? null; 27 | $this->schema = type_assert_shape($schema_dict, '\Slack\Hack\JsonSchema\Codegen\TSchema'); 28 | 29 | $context_config = $this->validatorConfig['context'] ?? null; 30 | if ($context_config is nonnull) { 31 | $root_schema = $context_config['root_schema'] ?? null; 32 | if ($root_schema is nonnull) { 33 | $this->rootSchema = type_assert_shape($root_schema, 'Slack\Hack\JsonSchema\Codegen\TSchema'); 34 | } 35 | 36 | $root_schema_path = $context_config['root_schema_path'] ?? null; 37 | if ($root_schema_path is nonnull) { 38 | $relative_path = Str\split($root_schema_path, '/') |> C\last($$) ?? ''; 39 | $this->setCurrentRefFileName($relative_path); 40 | } 41 | } 42 | } 43 | 44 | public function getCoerceDefault(): bool { 45 | $defaults = $this->validatorConfig['defaults'] ?? shape(); 46 | return $defaults['coerce'] ?? false; 47 | } 48 | 49 | public function getHackCodegenFactory(): HackCodegenFactory { 50 | return $this->factory; 51 | } 52 | 53 | public function getSchema(): TSchema { 54 | return $this->schema; 55 | } 56 | 57 | public function setSchema(TSchema $schema): void { 58 | $this->schema = $schema; 59 | } 60 | 61 | public function getClassName(): string { 62 | return $this->classname; 63 | } 64 | 65 | public function getFile(): CodegenFile { 66 | return $this->file; 67 | } 68 | 69 | public function setRootSchema(TSchema $schema): void { 70 | $this->rootSchema = $schema; 71 | } 72 | 73 | public function setRefsRootDirectory(string $path): void { 74 | $refs_config = $this->refsConfig; 75 | if ($refs_config === null) { 76 | $refs_config = shape(); 77 | } 78 | 79 | $refs_config['root_directory'] = $path; 80 | $this->refsConfig = $refs_config; 81 | } 82 | 83 | public function getRefsRootDirectory(): ?string { 84 | $refs_config = $this->refsConfig ?? null; 85 | if ($refs_config === null) { 86 | return null; 87 | } 88 | 89 | return $refs_config['root_directory'] ?? null; 90 | } 91 | 92 | public function getUniqueRefsConfig(): ?Codegen::TValidatorRefsUniqueConfig { 93 | $config = null; 94 | $refs_config = $this->refsConfig ?? null; 95 | if ($refs_config is nonnull) { 96 | $config = $refs_config['unique'] ?? null; 97 | } 98 | 99 | return $config; 100 | } 101 | 102 | public function getCodegenConfig(): Codegen::TCodegenConfig { 103 | return $this->codegenConfig; 104 | } 105 | 106 | public function getSanitizeStringConfig(): ?Codegen::TSanitizeStringConfig { 107 | return $this->validatorConfig['sanitize_string'] ?? null; 108 | } 109 | 110 | public function setCurrentRefFileName(string $fp): void { 111 | $this->currentRefFileName = $fp; 112 | } 113 | 114 | public function getCurrentRefFileName(): ?string { 115 | return $this->currentRefFileName; 116 | } 117 | 118 | public function getJsonSchemaCodegenConfig(): IJsonSchemaCodegenConfig { 119 | return $this->jsonSchemaCodegenConfig; 120 | } 121 | 122 | public function getRootSchema(): ?TSchema { 123 | return $this->rootSchema; 124 | } 125 | 126 | public function acknowledgeRef(string $ref): void { 127 | $this->seen_refs[] = $ref; 128 | } 129 | 130 | public function hasSeenRef(string $ref): bool { 131 | return C\contains($this->seen_refs, $ref); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /tests/examples/untyped-schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "object", 3 | "properties": { 4 | "all_of": { 5 | "allOf": [ 6 | { 7 | "type": "string" 8 | }, 9 | { 10 | "type": "integer" 11 | } 12 | ] 13 | }, 14 | "any_of": { 15 | "anyOf": [ 16 | { 17 | "type": "string" 18 | }, 19 | { 20 | "type": "integer" 21 | } 22 | ] 23 | }, 24 | "all_of_pass_through": { 25 | "allOf": [ 26 | { 27 | "type": "string", 28 | "sanitize": { 29 | "multiline": false 30 | } 31 | }, 32 | { 33 | "type": "string", 34 | "minLength": 1 35 | } 36 | ] 37 | }, 38 | "all_of_pass_through_second": { 39 | "allOf": [ 40 | { 41 | "type": "string", 42 | "sanitize": { 43 | "multiline": true 44 | } 45 | }, 46 | { 47 | "type": "string", 48 | "sanitize": { 49 | "multiline": false 50 | } 51 | } 52 | ] 53 | }, 54 | "all_of_coerce": { 55 | "allOf": [ 56 | { 57 | "type": "object", 58 | "coerce": true 59 | }, 60 | { 61 | "type": "object", 62 | "properties": { 63 | "property": { 64 | "type": "string" 65 | } 66 | } 67 | } 68 | ] 69 | }, 70 | "all_of_default": { 71 | "allOf": [ 72 | { 73 | "type": "object", 74 | "properties": { 75 | "property": { 76 | "type": "string", 77 | "default": "default" 78 | } 79 | } 80 | }, 81 | { 82 | "type": "object", 83 | "properties": { 84 | "numerical_property": { 85 | "type": "number", 86 | "default": 0 87 | } 88 | } 89 | } 90 | ] 91 | }, 92 | "all_of_default_first_schema_wins": { 93 | "allOf": [ 94 | { 95 | "type": "object", 96 | "properties": { 97 | "property": { 98 | "type": "string", 99 | "default": "actual_default" 100 | } 101 | } 102 | }, 103 | { 104 | "type": "object", 105 | "properties": { 106 | "property": { 107 | "type": "string", 108 | "default": "not_default" 109 | } 110 | } 111 | } 112 | ] 113 | }, 114 | "any_of_optimized_enum": { 115 | "anyOf": [ 116 | { 117 | "type": "object", 118 | "required": ["type"], 119 | "additionalProperties": false, 120 | "coerce": false, 121 | "properties": { 122 | "type": { 123 | "type": "string", 124 | "enum": ["first"] 125 | }, 126 | "string": { 127 | "type": "string" 128 | } 129 | } 130 | }, 131 | { 132 | "type": "object", 133 | "required": ["type"], 134 | "additionalProperties": false, 135 | "coerce": false, 136 | "properties": { 137 | "type": { 138 | "type": "string", 139 | "enum": ["second"] 140 | }, 141 | "integer": { 142 | "type": "integer" 143 | } 144 | } 145 | } 146 | ] 147 | }, 148 | "one_of_nullable_string": { 149 | "oneOf": [ 150 | { 151 | "type": "null" 152 | }, 153 | { 154 | "type": "string" 155 | } 156 | ] 157 | }, 158 | "one_of_string_and_int": { 159 | "oneOf": [ 160 | { 161 | "type": "integer" 162 | }, 163 | { 164 | "type": "string", 165 | "coerce": false 166 | } 167 | ] 168 | }, 169 | "one_of_string_and_vec": { 170 | "oneOf": [ 171 | { 172 | "type": "string", 173 | "coerce": false 174 | }, 175 | { 176 | "type": "array", 177 | "items": { 178 | "type": "integer" 179 | } 180 | } 181 | ] 182 | } 183 | } 184 | } 185 | --------------------------------------------------------------------------------