Group
Extension

JSON-Schema-Fit/lib/JSON/Schema/Fit.pm

package JSON::Schema::Fit;
$JSON::Schema::Fit::VERSION = '0.07';
# ABSTRACT: adjust data structure according to json-schema


use 5.010;
use strict;
use warnings;
use utf8;

use Carp;

use JSON;
use Storable;
use Scalar::Util qw/reftype/;
use List::Util qw/first none/;
use Math::Round qw/round nearest/;




sub booleans { return _attr('booleans', @_); }


sub numbers { return _attr('numbers', @_); }



sub round_numbers { return _attr('round_numbers', @_); }


sub clamp_numbers { return _attr('clamp_numbers', @_); }



sub strings { return _attr('strings', @_); }


sub hash_keys { return _attr('hash_keys', @_); }



sub fill_defaults { return _attr('fill_defaults', @_); }


sub replace_invalid_values { return _attr('replace_invalid_values', @_); }



# Store valid options as well as default values
my %valid_option =(
    ( map { ($_ => 1) } qw!booleans numbers round_numbers strings hash_keys! ),
    ( map { ($_ => 0) } qw!clamp_numbers fill_defaults replace_invalid_values! ),
);

sub new {
    my ($class, %opts) = @_;
    my $self = bless {}, $class;
    for my $k (keys %opts) {
        next unless exists $valid_option{$k};
        _attr($k, $self, $opts{$k});
    }
    return $self
}

sub _attr {
    my $attr = shift;
    my $self = shift;

    if (@_) {
        return $self->{$attr} = shift;
    } else {
        return $self->{$attr} //= $valid_option{$attr};
    }
}


sub get_adjusted {
    my ($self, $struc, $schema, $jpath) = @_;

    return $struc  if !ref $schema || reftype $schema ne 'HASH';

    if (
        exists $schema->{default} && (
            !defined $struc && $self->fill_defaults ||
            $self->replace_invalid_values && !$self->_is_valid($struc, $schema)
        )
    ) {
        return $self->_default_value($schema);
    }

    my $method = $self->_adjuster_by_type($schema->{type});
    return $struc  if !$method;
    return $self->$method($struc, $schema, $jpath);
}


sub _is_valid {
    my ($self, $struc, $schema) = @_;

    if (my $enum = $schema->{enum}) {
        return '' if none {my $v = $_; $struc eq $v} @$enum;
    }

    return 1;


    return grep { Compare($struc, $_) } @{$schema->{enum}};
}


sub _default_value {
    my ($self, $schema) = @_;
    return if !exists $schema->{default};

    my $default = $schema->{default};
    return ref $default ? Storable::dclone($default) : $default;
}


sub _adjuster_by_type {
    my ($self, $type) = @_;

    return if !$type;
    my $method = "_get_adjusted_$type";
    return $method if $self->can($method);
    return;
}


sub _get_adjusted_boolean {
    my ($self, $struc, $schema, $jpath) = @_;

    return $struc if !$self->booleans();
    return JSON::true if $struc;
    return JSON::false;
}


sub _get_adjusted_integer {
    my ($self, $struc, $schema, $jpath) = @_;

    return $struc  if !$self->numbers();
    my $result = $self->_get_adjusted_number($struc, $schema, $jpath);
    return round($result);
}


sub _get_adjusted_number {
    my ($self, $struc, $schema, $jpath) = @_;

    return $struc  if !$self->numbers();
    my $result = 0+$struc;

    if ($self->round_numbers) {
        my $quantum = $schema->{multipleOf} || $schema->{divisibleBy};
        $result = nearest $quantum, $result if $quantum;
    }

    if ($self->clamp_numbers) {
        $result = $schema->{maximum} if exists $schema->{maximum} and $result > $schema->{maximum};
        $result = $schema->{minimum} if exists $schema->{minimum} and $result < $schema->{minimum};
    }

    return $result;
}


sub _get_adjusted_string {
    my ($self, $struc, $schema, $jpath) = @_;

    return $struc  if !$self->strings();
    return "$struc";
}


sub _get_adjusted_array {
    my ($self, $struc, $schema, $jpath) = @_;

    croak "Structure is not ARRAY at $jpath"  if reftype $struc ne 'ARRAY';

    my $result = [];
    for my $i ( 0 .. $#$struc ) {
        push @$result, $self->get_adjusted($struc->[$i], $schema->{items}, $self->_jpath($jpath, $i));
    }

    return $result;
}

sub _get_adjusted_object {
    my ($self, $struc, $schema, $jpath) = @_;

    croak "Structure is not HASH at $jpath"  if reftype $struc ne 'HASH';

    my $result = {};
    my $keys_re;

    my $properties = $schema->{properties} || {};
    my $p_properties = $schema->{patternProperties} || {};

    if ($self->hash_keys() && exists $schema->{additionalProperties} && !$schema->{additionalProperties}) {
        my $keys_re_text = join q{|}, (
            keys %$p_properties,
            map {quotemeta} keys %$properties,
        );
        $keys_re = qr{^$keys_re_text$}x;
    }

    for my $key (keys %$struc) {
        next if $keys_re && $key !~ $keys_re;

        my $subschema = $properties->{$key};
        if (my $re_key = !$subschema && first {$key =~ /$_/x} keys %$p_properties) {
            $subschema = $p_properties->{$re_key};
        }

        $result->{$key} = $self->get_adjusted($struc->{$key}, $subschema, $self->_jpath($jpath, $key));
    }

    if ($self->fill_defaults) {
        for my $key (keys %$properties) {
            next if exists $result->{$key};
            my $subschema = $properties->{$key};
            next if !exists $subschema->{default};
            $result->{$key} = $self->_default_value($subschema);
        }
    }

    return $result;
}


sub _jpath {
    my ($self, $path, $key) = @_;
    $path //= q{$};

    return "$path.$key"  if $key =~ /^[_A-Za-z]\w*$/x;

    $key =~ s/(['\\])/\\$1/gx;
    return $path . "['$key']";
}


1;

__END__

=pod

=encoding UTF-8

=head1 NAME

JSON::Schema::Fit - adjust data structure according to json-schema

=head1 VERSION

version 0.07

=head1 SYNOPSIS

    my $data = get_dirty_result();
    # raw data: got { num => "1.99999999997", flag => "1", junk => {...} }
    my $bad_json = encode_json $data;
    # {"num":"1.99999999997","flag":"1","junk":{...}}

    # JSON::Schema-compatible
    my $schema = { type => 'object', additionalProperties => 0, properties => {
        num => { type => 'integer' },
        flag => { type => 'boolean' },
    }};
    my $prepared_data = JSON::Schema::Fit->new()->get_adjusted($data, $schema);
    my $cool_json = encode_json $prepared_data;
    # {"num":2,"flag":true}

=head1 DESCRIPTION

The main goal of this package is preparing data to be encoded as json according to schema.

Actions implemented:
adjusting value type (number/string/boolean),
rounding numbers,
filtering hash keys.

=head1 ATTRIBUTES

=head2 booleans

Explicitly set type for boolean values to JSON::true / JSON::false

Default: 1

=head2 numbers

Explicitly set type for numeric and integer values

Default: 1

=head2 round_numbers

Round numbers according to 'multipleOf' schema value

Default: 1

=head2 clamp_numbers

Crop numbers accordingly with 'maximum', 'minimum' attributes.

Values outside these limits will be set to the defined in the maximum/minimum
attributes. 

Default: 0

=head2 strings

Explicitly set type for strings

Default: 1

=head2 hash_keys

Filter out not allowed hash keys (where additionalProperties is false).

Default: 1

=head2 fill_defaults

Fill missing values that have 'default' attribute in schema.

Default: 0

=head2 replace_invalid_values

Replace incompatible with schema values with defaults (if defined).
For now, only presence in 'enum' is checked.

Default: 0

=head1 METHODS

=head2 new

    my $jsf = JSON::Schema::Fit->new(booleans => 0);

Create a new C<JSON::Schema::Fit> instance. See bellow for valid options.

=head2 get_adjusted

Returns "semi-copy" of data structure with adjusted values. Original data is not affected.

=head1 CONSTRUCTOR

=head1 SEE ALSO

Related modules: L<JSON>, L<JSON::Schema>.

Json-schema home: L<http://json-schema.org/>

=head1 AUTHOR

liosha <liosha@yandex-team.ru>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2019 by liosha.

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

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.