The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package ThaiSchema;
use strict;
use warnings;
use 5.010001;
our $VERSION = '0.02';
use parent qw/Exporter/;

our $STRICT = 0;
our $ALLOW_EXTRA = 0;
our @_ERRORS;
our $_NAME = '';

our @EXPORT = qw/
    match_schema
    type_int type_str type_number type_hash type_array type_maybe type_bool
/;

use JSON;
use B;
use Data::Dumper;

use Scalar::Util qw/blessed/;

sub match_schema {
    local @_ERRORS;
    local $_NAME = '';
    my $ok = _match_schema(@_);
    return wantarray ? ($ok, \@_ERRORS) : $ok;
}

sub _match_schema {
    my ($value, $schema) = @_;
    if (ref $schema eq 'HASH') {
        $schema = ThaiSchema::Hash->new($schema);
    }
    if (blessed $schema && $schema->can('match')) {
        if ($schema->match($value)) {
            return 1;
        } else {
            if ($schema->error) {
                push @_ERRORS, $_NAME .' '. $schema->error();
            }
            return 0;
        }
    } else {
        die "Unsupported schema: " . ref $schema;
    }
}

sub type_str() {
    ThaiSchema::Str->new();
}

sub type_int() {
    ThaiSchema::Int->new();
}

sub type_maybe($) {
    ThaiSchema::Maybe->new(shift);
}

sub type_number() {
    ThaiSchema::Number->new();
}

sub type_hash($) {
    ThaiSchema::Hash->new(shift);
}

sub type_array(;$) {
    ThaiSchema::Array->new(shift);
}

sub type_bool() {
    ThaiSchema::Bool->new()
}

package ThaiSchema::Hash;

sub new {
    my $class = shift;
    bless [$_[0]], $class;
}

sub match {
    my ($self, $value) = @_;
    return 0 unless ref $value eq 'HASH';

    my $schema = $self->[0];

    my $fail = 0;
    my %rest_keys = map { $_ => 1 } keys %$value;
    for my $key (keys %$schema) {
        local $_NAME = $_NAME ? "$_NAME.$key" : $key;
        if (not ThaiSchema::_match_schema($value->{$key}, $schema->{$key})) {
            $fail++;
        }
        delete $rest_keys{$key};
    }
    if (%rest_keys && !$ThaiSchema::ALLOW_EXTRA) {
        push @_ERRORS, 'have extra keys';
        return 0;
    }
    return !$fail;
}

sub error {
    return ();
}

package ThaiSchema::Array {
    sub new {
        my $class = shift;
        bless [$_[0]], $class;
    }
    sub match {
        my ($self, $value) = @_;
        return 0 unless ref $value eq 'ARRAY';
        if (defined $self->[0]) {
            for (my $i=0; $i<@{$value}; $i++) {
                local $_NAME = $_NAME . "[$i]";
                my $elem = $value->[$i];
                return 0 unless ThaiSchema::_match_schema($elem, $self->[0]);
            }
        }
        return 1;
    }
    sub error {
        return ();
    }
}

package ThaiSchema::Maybe {
    sub new {
        my $class = shift;
        bless [$_[0]], $class;
    }
    sub match {
        my ($self, $value) = @_;
        return 1 unless defined $value;
        return $self->[0]->match($value);
    }
    sub error { "is not maybe $_[0]->[0]" }
}

package ThaiSchema::Str {
    sub new {
        my $class = shift;
        bless {}, $class;
    }
    sub match {
        my ($self, $value) = @_;
        return 0 unless defined $value;
        if ($ThaiSchema::STRICT) {
            my $b_obj = B::svref_2object(\$value);
            my $flags = $b_obj->FLAGS;
            return 0 if $flags & ( B::SVp_IOK | B::SVp_NOK ) and !( $flags & B::SVp_POK ); # SvTYPE is IV or NV?
            return 1;
        } else {
            return not ref $value;
        }
    }
    sub error { "is not str" }
}

package ThaiSchema::Int {
    sub new {
        my $class = shift;
        bless {}, $class;
    }
    sub match {
        my ($self, $value) = @_;
        return 0 unless defined $value;
        if ($ThaiSchema::STRICT) {
            my $b_obj = B::svref_2object(\$value);
            my $flags = $b_obj->FLAGS;
            return 1 if $flags & ( B::SVp_IOK | B::SVp_NOK ) and int($value) == $value and !( $flags & B::SVp_POK ); # SvTYPE is IV or NV?
            return 0;
        } else {
            return $value =~ /^[1-9][0-9]*$/;
        }
    }
    sub error { "is not int" }
}

package ThaiSchema::Number {
    use Scalar::Util ();
    sub new {
        my $class = shift;
        bless {}, $class;
    }
    sub match {
        my ($self, $value) = @_;
        return 0 unless defined $value;
        if ($ThaiSchema::STRICT) {
            my $b_obj = B::svref_2object(\$value);
            my $flags = $b_obj->FLAGS;
            return 1 if $flags & ( B::SVp_IOK | B::SVp_NOK ) and !( $flags & B::SVp_POK ); # SvTYPE is IV or NV?
            return 0;
        } else {
            return Scalar::Util::looks_like_number($value);
        }
    }
    sub error { 'is not number' }
}

package ThaiSchema::Bool {
    use JSON;
    sub new {
        my $class = shift;
        bless {}, $class;
    }
    sub match {
        my ($self, $value) = @_;
        return 0 unless defined $value;
        return 1 if JSON::is_bool($value);
        return 1 if ref($value) eq 'SCALAR' && ($$value eq 1 || $$value eq 0);
        return 0;
    }
    sub error { 'is not bool' }
}

1;
__END__

=encoding utf8

=head1 NAME

ThaiSchema - Lightweight schema validator

=head1 SYNOPSIS

    use ThaiSchema;

    match_schema({x => 3}, {x => type_int});

=head1 DESCRIPTION

ThaiSchema is a lightweight schema validator.

=head1 FUNCTIONS

=over 4

=item type_int()

Is it a int value?

=item type_str()

Is it a str value?

=item type_maybe($child)

Is it maybe a $child value?

=item type_hash(\%scheama)

    type_hash(
        {
            x => type_str,
            y => type_int,
        }
    );

Is it a hash contains valid keys?

=item type_array()

    type_array(
        type_hash({
            x => type_str,
            y => type_int,
        })
    );

=item type_bool()

Is it a boolean value?

This function allows only JSON::true, JSON::false, \1, and \0.

=back

=head1 OPTIONS

=over 4

=item $STRICT

You can check a type more strictly.

This option is useful for checking JSON types.

=item $ALLOW_EXTRA

You can allow extra key in hashref.

=back

=head1 AUTHOR

Tokuhiro Matsuno E<lt>tokuhirom AAJKLFJEF@ GMAIL COME<gt>

=head1 SEE ALSO

=head1 LICENSE

Copyright (C) Tokuhiro Matsuno

This library is free software; you can redistribute it and/or modify
it under the same terms as Perl itself.

=cut