From e5755ddd80cf44dc5ba8a76b4495afb33e9bac0f Mon Sep 17 00:00:00 2001 From: Yorhel Date: Thu, 6 Mar 2025 10:16:12 +0100 Subject: [PATCH] Validate: Human-readable error messages --- FU.pm | 4 +-- FU/Validate.pm | 35 ++++++++++++++---- t/validate.t | 96 ++++++++++++++++++++++++++------------------------ 3 files changed, 79 insertions(+), 56 deletions(-) diff --git a/FU.pm b/FU.pm index 4d04562..57123a5 100644 --- a/FU.pm +++ b/FU.pm @@ -640,8 +640,8 @@ sub _getfield($data, @a) { require FU::Validate; my $schema = FU::Validate->compile(@a > 1 ? { keys => {@a} } : $a[0]); my $res = eval { $schema->validate($data) }; - fu->error(400, "Input validation failed") if $@; # TODO: More detailed error message - return @a == 2 ? $res->data->{$a[0]} : $res->data; + fu->error(400, "Input validation failed: $@") if $@; + return @a == 2 ? $res->{$a[0]} : $res; } sub query { diff --git a/FU/Validate.pm b/FU/Validate.pm index 296d78f..1c7c94b 100644 --- a/FU/Validate.pm +++ b/FU/Validate.pm @@ -321,8 +321,10 @@ sub _validate { sub validate($c, $input) { my $r = _validate($c, $input); return $input if !$r; - die bless $r, 'FU::Validate::err'; - $input + $r = bless $r, 'FU::Validate::err';; + my @e = $r->errors; + $r->{longmess} = Carp::longmess(@e > 1 ? join("\n",@e)."\n" : $e[0]); + die $r; } @@ -330,12 +332,31 @@ sub validate($c, $input) { package FU::Validate::err; use v5.36; +use FU::Util; -use overload '""' => sub { - # TODO: Better error message - require Data::Dumper; - Data::Dumper->new([{$_[0]->%*}])->Terse(1)->Pair(':')->Indent(0)->Sortkeys(1)->Dump."\n"; -}; +use overload '""' => sub { $_[0]{longmess} || join "\n", $_[0]->errors }; + +sub _fmtkey($k) { + $k =~ /^[a-zA-Z0-9_-]+$/ ? $k : FU::Util::json_format($k); +} + +sub _fmtval($v) { + eval { $v = FU::Util::json_format($v) }; "$v" +} + +sub errors($e, $prefix='') { + my $val = $e->{validation}; + my $p = $prefix ? "$prefix: " : ''; + $val eq 'keys' ? map errors($_, $prefix.'.'._fmtkey($_->{key})), $e->{errors}->@* : + $val eq 'missing' ? $prefix.'.'._fmtkey($e->{key}).': required key missing' : + $val eq 'values' ? map errors($_, $prefix."[$_->{index}]"), $e->{errors}->@* : + $val eq 'unique' ? $prefix."[$e->{index_b}] value '"._fmtval($e->{value_a})."' duplicated" : + $val eq 'required' ? "${p}required value missing" : + $val eq 'type' ? "${p}invalid type, expected '$e->{expected}' but got '$e->{got}'" : + $val eq 'unknown' ? ($e->{keys}->@* > 1 ? "${p}unknown keys: ".join(', ', _fmtkey($e->{keys})) : "${p}unknown key '"._fmtkey($e->{keys}[0])."'") : + $e->{error} ? errors($e->{error}, "${p}validation '$val'") : + "${p}failed validation '$val'"; +} 1; diff --git a/t/validate.t b/t/validate.t index 1bf0b35..b01352b 100644 --- a/t/validate.t +++ b/t/validate.t @@ -44,7 +44,7 @@ sub t($schema, $input, $output) { is_deeply $res, $output, "data ok $line"; } -sub f($schema, $input, $error) { +sub f($schema, $input, $error, @msg) { my $line = (caller)[2]; my $schema_copy = dclone([$schema])->[0]; @@ -53,14 +53,16 @@ sub f($schema, $input, $error) { ok !eval { FU::Validate->compile($schema, \%validations)->validate($input); 1 }, "eval $line"; is_deeply $schema, $schema_copy, "schema modification $line"; is_deeply $input, $input_copy, "input modification $line"; + delete $@->{longmess}; is_deeply { $@->%* }, $error, "err $line"; + is_deeply [$@->errors], \@msg, "errmsg $line"; } # default t {}, 0, 0; -f {}, '', { validation => 'required' }; -f {}, undef, { validation => 'required' }; +f {}, '', { validation => 'required' }, 'required value missing'; +f {}, undef, { validation => 'required' }, 'required value missing'; t { default => undef }, undef, undef; t { default => undef }, '', undef; t { defaultsub1 => 1 }, undef, 2; @@ -71,104 +73,104 @@ t { onerrorsub => 1 }, undef, 'FU::Validate::err'; # trim t {}, " Va\rl id \n ", 'Val id'; t { trim => 0 }, " Va\rl id \n ", " Va\rl id \n "; -f {}, ' ', { validation => 'required' }; +f {}, ' ', { validation => 'required' }, 'required value missing'; t { trim => 0 }, ' ', ' '; # arrays -f {}, [], { validation => 'type', expected => 'scalar', got => 'array' }; -f { type => 'array' }, 1, { validation => 'type', expected => 'array', got => 'scalar' }; +f {}, [], { validation => 'type', expected => 'scalar', got => 'array' }, "invalid type, expected 'scalar' but got 'array'"; +f { type => 'array' }, 1, { validation => 'type', expected => 'array', got => 'scalar' }, "invalid type, expected 'array' but got 'scalar'"; t { type => 'array' }, [], []; t { type => 'array' }, [undef,1,2,{}], [undef,1,2,{}]; t { type => 'array', scalar => 1 }, 1, [1]; -f { type => 'array', values => {} }, [undef], { validation => 'values', errors => [{ index => 0, validation => 'required' }] }; +f { type => 'array', values => {} }, [undef], { validation => 'values', errors => [{ index => 0, validation => 'required' }] }, "[0]: required value missing"; t { type => 'array', values => {} }, [' a '], ['a']; t { type => 'array', sort => 'str' }, [qw/20 100 3/], [qw/100 20 3/]; t { type => 'array', sort => 'num' }, [qw/20 100 3/], [qw/3 20 100/]; t { revnum => 1 }, [qw/20 100 3/], [qw/100 20 3/]; t { type => 'array', sort => 'num', unique => 1 }, [qw/3 2 1/], [qw/1 2 3/]; -f { type => 'array', sort => 'num', unique => 1 }, [qw/3 2 3/], { validation => 'unique', index_a => 1, value_a => 3, index_b => 2, value_b => 3 }; +f { type => 'array', sort => 'num', unique => 1 }, [qw/3 2 3/], { validation => 'unique', index_a => 1, value_a => 3, index_b => 2, value_b => 3 }, q{[2] value '"3"' duplicated}; t { type => 'array', unique => 1 }, [qw/3 1 2/], [qw/3 1 2/]; -f { type => 'array', unique => 1 }, [qw/3 1 3/], { validation => 'unique', index_a => 0, value_a => 3, index_b => 2, value_b => 3, key => 3 }; +f { type => 'array', unique => 1 }, [qw/3 1 3/], { validation => 'unique', index_a => 0, value_a => 3, index_b => 2, value_b => 3, key => 3 }, q{[2] value '"3"' duplicated}; t { uniquelength => 1 }, [[],[1],[1,2]], [[],[1],[1,2]]; -f { uniquelength => 1 }, [[],[1],[2]], { validation => 'unique', index_a => 1, value_a => [1], index_b => 2, value_b => [2], key => 1 }; +f { uniquelength => 1 }, [[],[1],[2]], { validation => 'unique', index_a => 1, value_a => [1], index_b => 2, value_b => [2], key => 1 }, q{[2] value '[1]' duplicated}; t { type => 'array', setundef => 1 }, [], undef; t { type => 'array', values => { type => 'any', setundef => 1 } }, [[]], [undef]; # hashes -f { type => 'hash' }, [], { validation => 'type', expected => 'hash', got => 'array' }; -f { type => 'hash' }, 'a', { validation => 'type', expected => 'hash', got => 'scalar' }; +f { type => 'hash' }, [], { validation => 'type', expected => 'hash', got => 'array' }, "invalid type, expected 'hash' but got 'array'"; +f { type => 'hash' }, 'a', { validation => 'type', expected => 'hash', got => 'scalar' }, "invalid type, expected 'hash' but got 'scalar'"; t { type => 'hash' }, {a=>[],b=>undef,c=>{}}, {}; -f { type => 'hash', keys => { a=>{} } }, {}, { validation => 'keys', errors => [{ key => 'a', validation => 'required' }] }; +f { type => 'hash', keys => { a=>{} } }, {}, { validation => 'keys', errors => [{ key => 'a', validation => 'required' }] }, '.a: required value missing'; t { type => 'hash', keys => { a=>{missing=>'ignore'} } }, {}, {}; t { type => 'hash', keys => { a=>{default=>undef} } }, {}, {a=>undef}; t { type => 'hash', keys => { a=>{missing=>'create',default=>undef} } }, {}, {a=>undef}; -f { type => 'hash', keys => { a=>{missing=>'reject'} } }, {}, {key => 'a', validation => 'missing'}; +f { type => 'hash', keys => { a=>{missing=>'reject'} } }, {}, {key => 'a', validation => 'missing'}, '.a: required key missing'; t { type => 'hash', keys => { a=>{} } }, {a=>' a '}, {a=>'a'}; # Test against in-place modification t { type => 'hash', keys => { a=>{} }, unknown => 'remove' }, { a=>1,b=>1 }, { a=>1 }; -f { type => 'hash', keys => { a=>{} }, unknown => 'reject' }, { a=>1,b=>1 }, { validation => 'unknown', keys => ['b'], expected => ['a'] }; +f { type => 'hash', keys => { a=>{} }, unknown => 'reject' }, { a=>1,b=>1 }, { validation => 'unknown', keys => ['b'], expected => ['a'] }, "unknown key 'b'"; t { type => 'hash', keys => { a=>{} }, unknown => 'pass' }, { a=>1,b=>1 }, { a=>1,b=>1 }; t { type => 'hash', setundef => 1 }, {}, undef; t { type => 'hash', unknown => 'reject', keys => { a=>{ type => 'any', setundef => 1}} }, {a=>[]}, {a=>undef}; # default validations -f { minlength => 3 }, 'ab', { validation => 'minlength', expected => 3, got => 2 }; +f { minlength => 3 }, 'ab', { validation => 'minlength', expected => 3, got => 2 }, "failed validation 'minlength'"; t { minlength => 3 }, 'abc', 'abc'; -f { maxlength => 3 }, 'abcd', { validation => 'maxlength', expected => 3, got => 4 }; +f { maxlength => 3 }, 'abcd', { validation => 'maxlength', expected => 3, got => 4 }, "failed validation 'maxlength'"; t { maxlength => 3 }, 'abc', 'abc'; t { minlength => 3, maxlength => 3 }, 'abc', 'abc'; -f { length => 3 }, 'ab', { validation => 'length', expected => 3, got => 2 }; -f { length => 3 }, 'abcd', { validation => 'length', expected => 3, got => 4 }; +f { length => 3 }, 'ab', { validation => 'length', expected => 3, got => 2 }, "failed validation 'length'"; +f { length => 3 }, 'abcd', { validation => 'length', expected => 3, got => 4 }, "failed validation 'length'"; t { length => 3 }, 'abc', 'abc'; t { length => [1,3] }, 'abc', 'abc'; -f { length => [1,3] }, 'abcd', { validation => 'length', expected => [1,3], got => 4 };; +f { length => [1,3] }, 'abcd', { validation => 'length', expected => [1,3], got => 4 }, "failed validation 'length'"; t { type => 'array', length => 0 }, [], []; -f { type => 'array', length => 1 }, [1,2], { validation => 'length', expected => 1, got => 2 }; +f { type => 'array', length => 1 }, [1,2], { validation => 'length', expected => 1, got => 2 }, "failed validation 'length'"; t { type => 'hash', length => 0 }, {}, {}; -f { type => 'hash', length => 1, unknown => 'pass' }, {qw/1 a 2 b/}, { validation => 'length', expected => 1, got => 2 }; +f { type => 'hash', length => 1, unknown => 'pass' }, {qw/1 a 2 b/}, { validation => 'length', expected => 1, got => 2 }, "failed validation 'length'"; t { type => 'hash', length => 1, keys => {a => {missing=>'ignore'}, b => {missing=>'ignore'}} }, {a=>1}, {a=>1}; t { regex => '^a' }, 'abc', 'abc'; # XXX: Can't use qr// here because t() does dclone(). The 'hex' test covers that case anyway. -f { regex => '^a' }, 'cba', { validation => 'regex', regex => '^a', got => 'cba' }; +f { regex => '^a' }, 'cba', { validation => 'regex', regex => '^a', got => 'cba' }, "failed validation 'regex'"; t { enum => [1,2] }, 1, 1; t { enum => [1,2] }, 2, 2; -f { enum => [1,2] }, 3, { validation => 'enum', expected => [1,2], got => 3 }; +f { enum => [1,2] }, 3, { validation => 'enum', expected => [1,2], got => 3 }, "failed validation 'enum'"; t { enum => 1 }, 1, 1; -f { enum => 1 }, 2, { validation => 'enum', expected => [1], got => 2 }; +f { enum => 1 }, 2, { validation => 'enum', expected => [1], got => 2 }, "failed validation 'enum'"; t { enum => {a=>1,b=>2} }, 'a', 'a'; -f { enum => {a=>1,b=>2} }, 'c', { validation => 'enum', expected => ['a','b'], got => 'c' }; +f { enum => {a=>1,b=>2} }, 'c', { validation => 'enum', expected => ['a','b'], got => 'c' }, "failed validation 'enum'"; t { anybool => 1 }, 1, true; t { anybool => 1 }, undef, false; t { anybool => 1 }, '', false; t { anybool => 1 }, {}, true; t { anybool => 1 }, [], true; t { anybool => 1 }, bless({}, 'test'), true; -f { bool => 1 }, 1, { validation => 'bool' }; +f { bool => 1 }, 1, { validation => 'bool' }, "failed validation 'bool'"; t { bool => 1 }, \1, true; my($true, $false) = (1,0); t { bool => 1 }, bless(\$true, 'boolean'), true; t { bool => 1 }, bless(\$false, 'boolean'), false; -f { bool => 1 }, bless(\$true, 'test'), { validation => 'bool' }; +f { bool => 1 }, bless(\$true, 'test'), { validation => 'bool' }, "failed validation 'bool'"; t { ascii => 1 }, 'ab c', 'ab c'; -f { ascii => 1 }, "a\nb", { validation => 'ascii', got => "a\nb" }; +f { ascii => 1 }, "a\nb", { validation => 'ascii', got => "a\nb" }, "failed validation 'ascii'"; # custom validations t { hex => 1 }, 'DeadBeef', 'DeadBeef'; -f { hex => 1 }, 'x', { validation => 'hex', error => { validation => 'regex', regex => "$validations{hex}{regex}", got => 'x' } }; +f { hex => 1 }, 'x', { validation => 'hex', error => { validation => 'regex', regex => "$validations{hex}{regex}", got => 'x' } }, "validation 'hex': failed validation 'regex'"; t { prefix => 'a' }, 'abc', 'abc'; -f { prefix => 'a' }, 'cba', { validation => 'prefix', error => { validation => 'func', result => '' } }; +f { prefix => 'a' }, 'cba', { validation => 'prefix', error => { validation => 'func', result => '' } }, "validation 'prefix': failed validation 'func'"; t { mybool => 1 }, 'abc', 1; t { mybool => 1 }, undef, 0; t { mybool => 1 }, '', 0; t { collapsews => 1 }, " \t\n ", ' '; t { collapsews => 1 }, ' x ', ' x '; t { collapsews => 1, trim => 1 }, ' x ', 'x'; -f { person => 1 }, 1, { validation => 'type', expected => 'hash', got => 'scalar' }; +f { person => 1 }, 1, { validation => 'type', expected => 'hash', got => 'scalar' }, "invalid type, expected 'hash' but got 'scalar'"; t { person => 1, default => 1 }, undef, 1; -f { person => 1 }, { sex => 1 }, { validation => 'person', error => { validation => 'keys', errors => [{ key => 'name', validation => 'required' }] } }; +f { person => 1 }, { sex => 1 }, { validation => 'person', error => { validation => 'keys', errors => [{ key => 'name', validation => 'required' }] } }, "validation 'person'.name: required value missing"; t { person => 1 }, { sex => undef, name => 'y' }, { sex => 1, name => 'y' }; -f { person => 1, keys => {age => {default => \'required'}} }, {name => 'x', sex => 'y'}, { validation => 'keys', errors => [{ key => 'age', validation => 'required' }] }; +f { person => 1, keys => {age => {default => \'required'}} }, {name => 'x', sex => 'y'}, { validation => 'keys', errors => [{ key => 'age', validation => 'required' }] }, '.age: required value missing'; t { person => 1, keys => {extra => {}} }, {name => 'x', sex => 'y', extra => 1}, { name => 'x', sex => 'y', extra => 1 }; -f { person => 1, keys => {extra => {}} }, {name => 'x', sex => 'y', extra => ''}, { validation => 'keys', errors => [{ key => 'extra', validation => 'required' }] }; +f { person => 1, keys => {extra => {}} }, {name => 'x', sex => 'y', extra => ''}, { validation => 'keys', errors => [{ key => 'extra', validation => 'required' }] }, '.extra: required value missing'; t { person => 1 }, {name => 'x', sex => 'y', extra => 1}, {name => 'x', sex => 'y', extra => 1}; t { person => 1, unknown => 'remove' }, {name => 'x', sex => 'y', extra => 1}, {name => 'x', sex => 'y'}; t { neverfails => 1, int => 1 }, undef, 'err'; @@ -176,7 +178,7 @@ t { neverfails => 1, int => 1 }, 'x', 'err'; t { neverfails => 1, int => 1, onerror => undef }, 'x', undef; # XXX: no way to 'unset' an inherited onerror clause, hmm. # numbers -sub nerr { +{ validation => 'num', got => $_[0] } } +sub nerr { ({ validation => 'num', got => $_[0] }, "failed validation 'num'") } t { num => 1 }, 0, 0; f { num => 1 }, '-', nerr '-'; f { num => 1 }, '00', nerr '00'; @@ -189,25 +191,25 @@ t { num => 1 }, '0.1e+5', '0.1e+5'; f { num => 1 }, '0.1e5.1', nerr '0.1e5.1'; t { int => 1 }, 0, 0; t { int => 1 }, -123, -123; -f { int => 1 }, -123.1, { validation => 'int', got => -123.1 }; +f { int => 1 }, -123.1, { validation => 'int', got => -123.1 }, "failed validation 'int'"; t { uint => 1 }, 0, 0; t { uint => 1 }, 123, 123; -f { uint => 1 }, -123, { validation => 'uint', got => -123 }; +f { uint => 1 }, -123, { validation => 'uint', got => -123 }, "failed validation 'uint'"; t { min => 1 }, 1, 1; -f { min => 1 }, 0.9, { validation => 'min', expected => 1, got => 0.9 }; -f { min => 1 }, 'a', { validation => 'min', error => nerr 'a' }; +f { min => 1 }, 0.9, { validation => 'min', expected => 1, got => 0.9 }, "failed validation 'min'"; +f { min => 1 }, 'a', { validation => 'min', error => (nerr 'a')[0] }, "validation 'min': failed validation 'num'"; t { max => 1 }, 1, 1; -f { max => 1 }, 1.1, { validation => 'max', expected => 1, got => 1.1 }; -f { max => 1 }, 'a', { validation => 'max', error => nerr 'a' }; +f { max => 1 }, 1.1, { validation => 'max', expected => 1, got => 1.1 }, "failed validation 'max'"; +f { max => 1 }, 'a', { validation => 'max', error => (nerr 'a')[0] }, "validation 'max': failed validation 'num'"; t { range => [1,2] }, 1, 1; t { range => [1,2] }, 2, 2; -f { range => [1,2] }, 0.9, { validation => 'range', error => { validation => 'min', expected => 1, got => 0.9 } }; -f { range => [1,2] }, 2.1, { validation => 'range', error => { validation => 'max', expected => 2, got => 2.1 } }; +f { range => [1,2] }, 0.9, { validation => 'range', error => { validation => 'min', expected => 1, got => 0.9 } }, "validation 'range': failed validation 'min'"; +f { range => [1,2] }, 2.1, { validation => 'range', error => { validation => 'max', expected => 2, got => 2.1 } }, "validation 'range': failed validation 'max'"; #t { range => [1,2] }, 'a', 'a', { validation => 'range', error => { validation => 'max', error => nerr 'a' } }; # XXX: Error validation type depends on evaluation order # email template use utf8; -f { email => 1 }, $_, { validation => 'email', got => $_ } for ( +f { email => 1 }, $_, { validation => 'email', got => $_ }, "failed validation 'email'" for ( 'abc.com', 'abc@localhost', 'abc@10.0.0.', @@ -228,10 +230,10 @@ t { email => 1 }, $_, $_ for ( 'abc@x-y_z.example', ); my $long = 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@xxxxxxxxxxxxxxxxxxxx.xxxxxxxxxxxxxxxxxxxxxxxx.xxxxx'; -f { email => 1 }, $long, { validation => 'email', error => { validation => 'maxlength', got => 255, expected => 254 } }; +f { email => 1 }, $long, { validation => 'email', error => { validation => 'maxlength', got => 255, expected => 254 } }, "validation 'email': failed validation 'maxlength'"; # weburl template -f { weburl => 1 }, $_, { validation => 'weburl', got => $_ } for ( +f { weburl => 1 }, $_, { validation => 'weburl', got => $_ }, "failed validation 'weburl'" for ( 'http', 'http://', 'http:///',