diff --git a/.gitignore b/.gitignore index 2e35b5c..fa78a76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,11 @@ .DS_Store .php_cs.cache .php_cs.dist +.php-cs-fixer.php +.php-cs-fixer.cache .phpunit.result.cache composer.lock /assets/ /phpunit.xml.bak .idea -.prettierrc.yaml \ No newline at end of file +.prettierrc.yaml diff --git a/src/Assertions.php b/src/Assertions.php index 272c555..bd9e94e 100644 --- a/src/Assertions.php +++ b/src/Assertions.php @@ -132,7 +132,7 @@ public function assertErrorsContain() return function ($errors) { return $this->runAssertion(function () use ($errors) { self::assertJson([ - 'errors' => Arr::wrap($errors), + 'specErrors' => Arr::wrap($errors), ]); return $this; diff --git a/src/Exceptions/SchemaValidationException.php b/src/Exceptions/SchemaValidationException.php index 6edef85..dce353a 100644 --- a/src/Exceptions/SchemaValidationException.php +++ b/src/Exceptions/SchemaValidationException.php @@ -4,12 +4,13 @@ use Opis\JsonSchema\Errors\ErrorFormatter; use Opis\JsonSchema\Errors\ValidationError; +use Spectator\Support\Format; use Symfony\Component\Console\Exception\ExceptionInterface; abstract class SchemaValidationException extends \Exception implements ExceptionInterface { /** - * @var + * @var array */ protected array $errors = []; @@ -18,7 +19,7 @@ abstract class SchemaValidationException extends \Exception implements Exception * @param ValidationError $error * @return static */ - public static function withError(string $message, ValidationError $error) + public static function withError(string $message, ValidationError $error): self { $instance = new static($message); @@ -44,7 +45,7 @@ protected function setErrors(ValidationError $error) * * @return array */ - public function getErrors() + public function getErrors(): array { return $this->errors; } @@ -63,21 +64,22 @@ public function hasErrors(): bool * Given a schema and ValidationError, returns a helpful error message where the validation * errors are mapped over a structured schema representation. * - * @param array $schema A JSON schema. + * @param array|object $schema A JSON schema. * @param ValidationError $validation_error A validation error generated by Opis\JsonSchema. * @return string */ - public static function validationErrorMessage($schema, $validation_error) + public static function validationErrorMessage($schema, ValidationError $validation_error): string { // Capture the validation error as a map containing each (sub)error's location, keyword, and message. - $error_formatted = SchemaValidationException::formatValidationError($validation_error, false); + $error_formatted = self::formatValidationError($validation_error, false); // Capture the schema as a map of display strings keyed by a location. $schema = json_decode(json_encode($schema), true); - $schema_formatted = SchemaValidationException::formatSchema($schema, '#', '', [], 0); + $schema_formatted = self::formatSchema($schema, '#', '', [], 0); // Create a map of all (sub)error's where the key is the "instanceLocation" and the value is the error. $error_location_map = []; + if (isset($error_formatted['errors'])) { foreach ($error_formatted['errors'] as $sub_error) { $error_location_map[$sub_error['instanceLocation']] = $sub_error; @@ -87,6 +89,7 @@ public static function validationErrorMessage($schema, $validation_error) // Create a map of all (sub)error's where the key is the "keywordLocation" with certain keywords // stripped away. The values are the errors. $error_keyword_location_map = []; + if (isset($error_formatted['errors'])) { foreach ($error_formatted['errors'] as $sub_error) { $keywords = ['/required', '/properties', '/type', '/format']; @@ -98,22 +101,25 @@ public static function validationErrorMessage($schema, $validation_error) // Display each item. If the item is keyed by a location matching an error, then display the // error alongside the item. $strings = []; + if (! is_null($schema_formatted)) { foreach ($schema_formatted as $key => $schema_item) { if (isset($error_location_map[$key])) { - $strings[] = $schema_item.' <== '.$error_location_map[$key]['error']; + $schema_item = self::colorize($schema_item, Format::TEXT_LIGHT_GREY); + $strings[] = $schema_item.' <== '.self::colorize($error_location_map[$key]['error'], Format::TEXT_RED); } elseif (isset($error_keyword_location_map[$key])) { - $strings[] = $schema_item.' <== '.$error_keyword_location_map[$key]['error']; + $schema_item = self::colorize($schema_item, Format::TEXT_LIGHT_GREY); + $strings[] = $schema_item.' <== '.self::colorize($error_keyword_location_map[$key]['error'], Format::TEXT_RED); } else { - $strings[] = $schema_item; + $strings[] = self::colorize($schema_item, Format::TEXT_GREEN); } } } // Display the validation error alongside the expected schema with each (sub)error mapped over it. - $error_flat = join("\n", SchemaValidationException::formatValidationError($validation_error, true)); + $error_flat = self::colorize(implode("\n", self::formatValidationError($validation_error, true)), Format::TEXT_LIGHT_GREY, Format::STYLE_ITALIC); - return "---\n\n".$error_flat."\n\n".join("\n", $strings)."\n\n ---"; + return "---\n\n".$error_flat."\n\n".implode("\n", $strings)."\n\n"; } /** @@ -123,7 +129,7 @@ public static function validationErrorMessage($schema, $validation_error) * @param bool $flat Should the formatted error be flat (a simple array) or structured? * @return array|string */ - public static function formatValidationError($validation_error, $flat = false) + public static function formatValidationError(ValidationError $validation_error, bool $flat = false) { $formatter = new ErrorFormatter(); @@ -165,7 +171,7 @@ public static function formatValidationError($validation_error, $flat = false) * @param int $indent_level Represents how much newly added values should be indented. * @return array */ - public static function formatSchema($schema, $location_current, $key_current, $keys_required, $indent_level) + public static function formatSchema(array $schema, string $location_current, string $key_current, array $keys_required, int $indent_level): array { $keys_at_location = array_flip(array_keys($schema)); $schema_map = []; @@ -181,12 +187,12 @@ public static function formatSchema($schema, $location_current, $key_current, $k // create entry for polymorphic schema $location_current .= '/'.$polymorphic_key; - $display_string = SchemaValidationException::schemaItemDisplayString($polymorphic_key, '', $key_current, ''); - $schema_map[$location_current] = SchemaValidationException::indentedDisplayString($display_string, $indent_level); + $display_string = self::schemaItemDisplayString($polymorphic_key, '', $key_current, ''); + $schema_map[$location_current] = self::indentedDisplayString($display_string, $indent_level); $indent_level = ++$indent_level; foreach ($schema[$polymorphic_key] as $index => $next_schema) { - $schema_map = array_merge($schema_map, SchemaValidationException::formatSchema($next_schema, $location_current.'/'.$index, $key_current, [], $indent_level)); + $schema_map = array_merge($schema_map, self::formatSchema($next_schema, $location_current.'/'.$index, $key_current, [], $indent_level)); } return $schema_map; @@ -244,39 +250,39 @@ public static function formatSchema($schema, $location_current, $key_current, $k } // create entry for object schema - $display_string = SchemaValidationException::schemaItemDisplayString( + $display_string = self::schemaItemDisplayString( 'object', $type_modifier, $key_current, $key_modifier ); - $schema_map[$location_current] = SchemaValidationException::indentedDisplayString($display_string, $indent_level); + $schema_map[$location_current] = self::indentedDisplayString($display_string, $indent_level); - // create entires for all object properties + // create entries for all object properties $indent_level = ++$indent_level; foreach ($schema['properties'] as $key => $next_schema) { if (isset($schema['required'])) { - $schema_map = array_merge($schema_map, SchemaValidationException::formatSchema($next_schema, $location_current, $key, $schema['required'], $indent_level)); + $schema_map = array_merge($schema_map, self::formatSchema($next_schema, $location_current, $key, $schema['required'], $indent_level)); } else { - $schema_map = array_merge($schema_map, SchemaValidationException::formatSchema($next_schema, $location_current, $key, [], $indent_level)); + $schema_map = array_merge($schema_map, self::formatSchema($next_schema, $location_current, $key, [], $indent_level)); } } break; case 'array': // create entry for array schema - $display_string = SchemaValidationException::schemaItemDisplayString('array', $type_modifier, $key_current, $key_modifier); - $schema_map[$location_current] = SchemaValidationException::indentedDisplayString($display_string, $indent_level); + $display_string = self::schemaItemDisplayString('array', $type_modifier, $key_current, $key_modifier); + $schema_map[$location_current] = self::indentedDisplayString($display_string, $indent_level); // create entry for array's items $next_schema = $schema['items']; - $schema_map = array_merge($schema_map, SchemaValidationException::formatSchema($next_schema, $location_current.'/items', '', [], ++$indent_level)); + $schema_map = array_merge($schema_map, self::formatSchema($next_schema, $location_current.'/items', '', [], ++$indent_level)); break; default: // create entry for basic schema - $final_type = isset($schema['enum']) ? $type.' ['.join(', ', $schema['enum']).']' : $type; - $display_string = SchemaValidationException::schemaItemDisplayString($final_type, $type_modifier, $key_current, $key_modifier); - $schema_map[$location_current] = SchemaValidationException::indentedDisplayString($display_string, $indent_level); + $final_type = isset($schema['enum']) ? $type.' ['.implode(', ', $schema['enum']).']' : $type; + $display_string = self::schemaItemDisplayString($final_type, $type_modifier, $key_current, $key_modifier); + $schema_map[$location_current] = self::indentedDisplayString($display_string, $indent_level); break; } @@ -296,7 +302,7 @@ public static function formatSchema($schema, $location_current, $key_current, $k * @param string $key_modifier A modifier for the key (ex: "?" in "string?"). * @return string */ - public static function schemaItemDisplayString($type, $type_modifier = '', $key = '', $key_modifier = '') + protected static function schemaItemDisplayString(string $type, string $type_modifier = '', string $key = '', string $key_modifier = ''): string { $key_final = $key.$key_modifier; $type_final = $type.$type_modifier; @@ -311,8 +317,21 @@ public static function schemaItemDisplayString($type, $type_modifier = '', $key * @param int $indent_level The level of indentation to apply to the display string. * @return string */ - public static function indentedDisplayString($display_string, $indent_level = 0) + protected static function indentedDisplayString(string $display_string, int $indent_level = 0): string { return str_repeat(' ', $indent_level).$display_string; } + + /** + * Colorize text. + * + * @param string $text + * @param string $text_color + * @param string $style + * @return string + */ + protected static function colorize(string $text, string $text_color, string $style = ''): string + { + return "\e[{$text_color};{$style}m{$text}\e[0m"; + } } diff --git a/src/Support/Format.php b/src/Support/Format.php new file mode 100644 index 0000000..54f9df0 --- /dev/null +++ b/src/Support/Format.php @@ -0,0 +1,16 @@ +assertValidRequest() ->assertValidResponse(200); } + + public function test_errors_contain() + { + Route::get('/users', function () { + return [ + [ + 'id' => 'invalid', + ], + ]; + })->middleware(Middleware::class); + + $this->getJson('/users') + ->assertValidRequest() + ->assertValidResponse() + ->assertValidationMessage('All array items must match schema') + ->assertErrorsContain([ + 'All array items must match schema', + 'The properties must match schema: id', + 'The data (string) must match the type: number', + ]); + } }