Skip to content

Commit

Permalink
#70 Add color to error messages
Browse files Browse the repository at this point in the history
  • Loading branch information
hotmeteor committed Dec 10, 2021
1 parent 2bb25e3 commit 31e3c19
Show file tree
Hide file tree
Showing 5 changed files with 90 additions and 32 deletions.
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
.prettierrc.yaml
2 changes: 1 addition & 1 deletion src/Assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
79 changes: 49 additions & 30 deletions src/Exceptions/SchemaValidationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];

Expand All @@ -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);

Expand All @@ -44,7 +45,7 @@ protected function setErrors(ValidationError $error)
*
* @return array
*/
public function getErrors()
public function getErrors(): array
{
return $this->errors;
}
Expand All @@ -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;
Expand All @@ -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'];
Expand All @@ -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";
}

/**
Expand All @@ -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();

Expand Down Expand Up @@ -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 = [];
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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";
}
}
16 changes: 16 additions & 0 deletions src/Support/Format.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace Spectator\Support;

class Format
{
// https://joshtronic.com/2013/09/02/how-to-use-colors-in-command-line-output/

const TEXT_GREEN = '0;32';
const TEXT_RED = '0;31';
const TEXT_WHITE = '1;37';
const TEXT_LIGHT_GREY = '0;37';
const TEXT_DARK_GREY = '1;30';

const STYLE_ITALIC = '3';
}
21 changes: 21 additions & 0 deletions tests/ResponseValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -522,4 +522,25 @@ public function test_response_example()
->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',
]);
}
}

0 comments on commit 31e3c19

Please sign in to comment.