diff --git a/FHEM/98_JsonMod.pm b/FHEM/98_JsonMod.pm new file mode 100644 index 000000000..032099538 --- /dev/null +++ b/FHEM/98_JsonMod.pm @@ -0,0 +1,1633 @@ +# $Id$ +############################################################################### +# +# This file is part of fhem. +# +# Fhem is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 of the License, or +# (at your option) any later version. +# +# Fhem is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with fhem. If not, see . +# +# +############################################################################### + +package main; + +use 5.018; +use feature qw( lexical_subs ); + +use strict; +use warnings; +use utf8; +use Time::Local qw( timelocal timegm ); +use Text::Balanced qw ( extract_codeblock extract_delimited ); +use HttpUtils; + +#use Memory::Usage; + +no warnings qw( experimental::lexical_subs ); + +sub JsonMod_Initialize { + my ($hash) = @_; + + my @attrList; + { + no warnings qw( qw ); + @attrList = qw( + httpHeader:textField-long + httpTimeout + readingList:textField-long + disable:0,1 + interval + ); + }; + + $hash->{'DefFn'} = 'JsonMod_Define'; + $hash->{'UndefFn'} = 'JsonMod_Undef'; + $hash->{'DeleteFn'} = 'JsonMod_Delete'; + $hash->{'SetFn'} = 'JsonMod_Set'; + $hash->{'AttrFn'} = 'JsonMod_Attr'; + $hash->{'NotifyFn'} = 'JsonMod_Notify'; + $hash->{'NOTIFYDEV'} = 'TYPE=Global'; + #$hash->{'NotifyOrderPrefix'} = "50-"; + $hash->{'AttrList'} = join(' ', @attrList)." $readingFnAttributes "; + + return undef; +}; + +sub JsonMod_Define { + my ($hash, $def) = @_; + my ($name, $type, $source) = split /\s/, $def, 3; + + my $cvsid = '$Id$'; + $cvsid =~ s/^.*pm\s//; + $cvsid =~ s/Z\s\S+\s\$$//; + + $hash->{'CONFIG'}->{'IN_REQUEST'} = 0; + $hash->{'CRON'} = JsonMod::Cron->new(); + + return "no FUUID, is fhem up to date?" if (not $hash->{'FUUID'}); + return "wrong source definition" if ($source !~ m/^(https:|http:|file:)/); + $hash->{'CONFIG'}->{'SOURCE'} = $source; + + InternalTimer(0, \&JsonMod_Run, $hash) if ($init_done); + return; +}; + +# reread / temporary remove +sub JsonMod_Undef { + my ($hash, $name) = @_; + RemoveInternalTimer($hash, \&JsonMod_DoTimer); + return; +}; + +# delete / permanently remove +sub JsonMod_Delete { + my ($hash, $name) = @_; + my $error; + # remove secret + setKeyValue($hash->{'FUUID'}, undef); + return $error; +}; + +sub JsonMod_Run { + my ($hash) = @_; + my $name = $hash->{'NAME'}; + + JsonMod_ReadPvtConfig($hash); + return if IsDisabled($name); + + my $cron = AttrVal($name, 'interval', '0 * * * *'); + $hash->{'CONFIG'}->{'CRON'} = \$cron; + JsonMod_StartTimer($hash); + JsonMod_ApiRequest($hash); + return; +}; + +sub JsonMod_Set { + my ($hash, $name, $cmd, @args) = @_; + + return "Unknown argument $cmd, choose one of secret" if ($cmd eq '?'); + + if ($cmd eq 'secret') { + if (not $args[1] and (exists($hash->{'CONFIG'}->{'SECRET'}->{$args[0]}))) { + delete $hash->{'CONFIG'}->{'SECRET'}->{$args[0]}; + JsonMod_WritePvtConfig($hash); + } elsif ($args[1]) { + $hash->{'CONFIG'}->{'SECRET'}->{$args[0]} = \$args[1]; + JsonMod_WritePvtConfig($hash); + }; + return; + }; + + if ($cmd eq 'test') { + my $filename = './log/goessner.json'; + my $data; + open(my $fh, '<', $filename) or return "cannot open file $filename"; + { + local $/; + $data = <$fh>; + } + close($fh); + my @test = qw ( + $..* + $.store.book[*].author + $..author + $.store..price + $..book[2] + ); + my $json = JsonMod::JSON::StreamReader->new()->parse($data); + my $path = JsonMod::JSON::Path->new($json); + foreach my $q (@test) { + print "****************************************************\n"; + my $query = $path->get($q); + $query->getResultNormVal(); + print "****************************************************\n"; + }; + my $query = $path->get('$.store.book[?(@.price < 10)]'); + $query->getResultNormVal(); + }; + + return; +}; + +sub JsonMod_Attr { + my ($cmd, $name, $attrName, $attrValue) = @_; + my $hash = $defs{$name}; + $attrValue //= ''; + #my $result; + + if ($cmd eq 'set') { + if ($attrName eq 'disable') { + if ($attrValue) { + JsonMod_StopTimer($hash); + } else { + JsonMod_StopTimer($hash); + JsonMod_StartTimer($hash); # unless IsDisabled($name); + }; + }; + if ($attrName eq 'interval') { + if (split(/ /, $attrValue) == 5) { + if ($hash->{'CRON'}->validate($attrValue)) { + $hash->{'CONFIG'}->{'CRON'} = \$attrValue; + return if (!$init_done); + JsonMod_StopTimer($hash); + JsonMod_StartTimer($hash) unless IsDisabled($name); + return; + } else { + return "wrong interval expression (cron)" + }; + }; + return "wrong interval expression"; + }; + }; + if ($cmd eq 'delete') { + if ($attrName eq 'interval') { + $hash->{'CONFIG'}->{'CRON'} = \'0 * * * *'; + JsonMod_StopTimer($hash); + JsonMod_StartTimer($hash); # unless IsDisabled($name); + }; + }; +}; + +sub JsonMod_Notify { + my ($hash, $dev) = @_; + my $name = $hash->{'NAME'}; + return undef if(IsDisabled($name)); + + my $events = deviceEvents($dev, 1); + return if(!$events); + + foreach my $event (@{$events}) { + my @e = split /\s/, $event; + JsonMod_Logger($hash, 5, 'event:[%s], device:[%s]', $event, $dev->{'NAME'}); + if ($dev->{'TYPE'} eq 'Global') { + if ($e[0] and $e[0] eq 'INITIALIZED') { + JsonMod_Run($hash); + }; + }; + }; + return; +}; + +# retrieve secrets +sub JsonMod_ReadPvtConfig { + my ($hash) = @_; + + my sub clean { + $hash->{'CONFIG'}->{'SECRET'} = {}; + return; + }; + + my ($error, $data) = getKeyValue($hash->{'FUUID'}); + if ($error or not $data) { + return clean(); + } else { + $data = MIME::Base64::decode($data); + $data = JsonMod::JSON::StreamReader->new()->parse($data) or do {return clean()}; + return clean() if (ref($data) ne 'HASH'); + }; + + foreach my $k (keys %{$data->{'SECRET'}}) { + $hash->{'CONFIG'}->{'SECRET'}->{$k} = \$data->{'SECRET'}->{$k}; + }; + $hash->{'SECRETS'} = join ", ", keys (%{$hash->{'CONFIG'}->{'SECRET'}}); + return; +}; + +# store secrets +sub JsonMod_WritePvtConfig { + my ($hash) = @_; + + my $data; + foreach my $k (keys (%{$hash->{'CONFIG'}->{'SECRET'}})) { + $data->{'SECRET'}->{$k} = ${$hash->{'CONFIG'}->{'SECRET'}->{$k}}; + }; + $hash->{'SECRETS'} = join ", ", keys (%{$hash->{'CONFIG'}->{'SECRET'}}); + my $key = $hash->{'FUUID'}; + my $val = MIME::Base64::encode(JsonMod::JSON::StreamWriter->new()->parse($data)); + my $error = setKeyValue($key, $val); + return; +}; + +sub JsonMod_DoReadings { + my ($hash, $data) = @_; + my $name = $hash->{'NAME'}; + + my $path = JsonMod::JSON::Path->new($data); + + my $newReadings = {}; + my $oldReadings = {}; + foreach my $key (keys %{$hash->{'READINGS'}}) { + $oldReadings->{$key} = 0; + }; + + my sub jsonPathf { + no warnings qw( redundant missing ); + my ($jsonPathExpression, $format) = @_; + $format //= '%s'; + my $value = $path->get($jsonPathExpression)->getResultValue(); + #$path->get($jsonPathExpression)->getResultNormVal(); + $value = $value->[0] if (ref($value) eq 'ARRAY' and scalar(@{$value}) > 0); + if (defined($value)) { + return sprintf($format, $value); + } else { + return undef; + }; + }; + + my sub jsonPath { + my ($jsonPathExpression) = @_; + return $path->get($jsonPathExpression)->getResultValue(); + }; + + # depraced + my sub singleReading { + my ($reading, $value, $default) = @_; + $value = $value->[0] if (ref($value) eq 'ARRAY' and scalar(@{$value}) > 0); + $value //= $default; + $newReadings->{$reading} = $value; + $oldReadings->{$reading} = 1; + return; + }; + + my sub concat { + my @args = @_; + return sub { + my ($o) = @_; + my $result = ''; + foreach my $arg (@args) { + if (ref($arg) eq 'CODE') { + $result .= $arg->($o); + } elsif (ref($arg) eq '') { + $result .= $arg; + } else { + die('syntax'); + }; + }; + return $result; + }; + }; + + my sub propertyf { + my ($p, $default, $format) = @_; + $default //= ''; + $format //= ''; + return sub { + my ($o) = @_; + if (ref($o) eq 'CODE') { + return $o->($p, $default); + } elsif (ref($o) eq 'HASH') { + my $result = $o->{$p} if (exists($o->{$p})); + if (defined($result)) { + if (ref($result) eq '') { + return sprintf ($format, $result); + } else { + return $result; + }; + } else { + return $default; + }; + } elsif (ref($o) eq 'ARRAY') { + my $result = $o->[$p] if ((scalar @{$o}) > ($p + 0)); + if (defined($result)) { + if (ref($result) eq '') { + return sprintf ($format, $result); + } else { + return $result; + }; + } else { + return $default; + }; + } elsif (ref($o) eq '') { + return $o; + } else { + die('syntax'); + }; + }; + }; + + my sub property { + my ($p, $default) = @_; + $default //= ''; + return sub { + my ($o) = @_; + if (ref($o) eq 'CODE') { + return $o->($p, $default); + } elsif (ref($o) eq 'HASH') { + return $o->{$p} if (exists($o->{$p})); + return $default; + } elsif (ref($o) eq 'ARRAY') { + return $o->[$p] if ((scalar @{$o}) > ($p + 0)); + return $default; + } elsif (ref($o) eq '') { + return $o; + } else { + die('syntax'); + }; + }; + }; + + my $_index = 0; + my sub index { + #my $index = 0; + return sub { + return $_index; + }; + }; + + my sub multi { + my ($value, @refs) = @_; + die ('jsonPath result not a list') if (ref($value) ne 'ARRAY'); + + $_index = 0; + foreach my $element (@{$value}) { + #use Data::Dumper; + #print Dumper $element; + my @reading; + foreach my $ref (@refs) { + push @reading, $ref->($element); + }; + $_index++; + $newReadings->{$reading[0]} = $reading[1]; + $oldReadings->{$reading[0]} = 1; + }; + }; + + # value (mostly jsonPath) / reading name / default if value is not available + my sub single { + my ($value, $reading, $default) = @_; + $value = $value->[0] if (ref($value) eq 'ARRAY' and scalar(@{$value}) > 0); + #use Data::Dumper; + #print $value; + $value //= $default; + $newReadings->{$reading} = $value; + $oldReadings->{$reading} = 1; + return; + }; + + if (my $readingList = AttrVal($name, 'readingList', '')) { + my $NAME = $name; + if (not eval $readingList and $@) { + JsonMod_Logger($hash, 2, 'error while evaluating readingList: %s', $@); + return; + }; + if (keys %{$newReadings}) { + readingsBeginUpdate($hash); + foreach my $k (keys %{$newReadings}) { + readingsBulkUpdate($hash, $k, $newReadings->{$k}); + }; + # not used anymore + foreach my $k (keys %{$oldReadings}) { + readingsDelete($hash, $k) if ($oldReadings->{$k} == 0); + }; + readingsEndUpdate($hash, 1); + }; + }; +}; + +sub JsonMod_StartTimer { + my ($hash) = @_; + my $name = $hash->{'NAME'}; + + my $cron = ${$hash->{'CONFIG'}->{'CRON'}}; + my @t = localtime(Time::HiRes::time()); + $t[4] += 1; + $t[5] += 1900; + my @r = $hash->{'CRON'}->next($cron, @t); + my $ts = timelocal(0, $r[0], $r[1], $r[2], $r[3] -1, $r[4] -1900); + $hash->{'NEXT'} = sprintf('%04d-%02d-%02d %02d:%02d:%02d', $r[4], $r[3], $r[2], $r[1], $r[0], 0); + JsonMod_Logger($hash, 4, 'next request: %04d.%02d.%02d %02d:%02d:%02d', $r[4], $r[3], $r[2], $r[1], $r[0], 0); + InternalTimer($ts, \&JsonMod_DoTimer, $hash); + return; +}; + +sub JsonMod_StopTimer { + my ($hash) = @_; + $hash->{'NEXT'} = 'NEVER'; + RemoveInternalTimer($hash, \&JsonMod_DoTimer); + return; +}; + +sub JsonMod_DoTimer { + my ($hash) = @_; + JsonMod_Logger($hash, 4, 'start request'); + JsonMod_StartTimer($hash); + # request in flight ? cancel + return if ($hash->{'CONFIG'}->{'IN_REQUEST'}); + JsonMod_ApiRequest($hash); + return; +}; + +sub JsonMod_ApiRequest { + my ($hash) = @_; + my $name = $hash->{'NAME'}; + + # prevent simultaneous request + return if ($hash->{'CONFIG'}->{'IN_REQUEST'}); + $hash->{'CONFIG'}->{'IN_REQUEST'} = 1; + + my $param = { + 'hash' => $hash, + 'cron' => $hash->{'CONFIG'}->{'CRON'}, + 'callback' => \&JsonMod_ApiResponse + }; + + my $source = $hash->{'CONFIG'}->{'SOURCE'}; + # fill in SECRET if available + $source =~ s/(\[.+?\])/(exists($hash->{'CONFIG'}->{'SECRET'}->{substr($1,1,length($1)-2)}))?${$hash->{'CONFIG'}->{'SECRET'}->{substr($1,1,length($1)-2)}}:$1/eg and + $param->{'hideurl'} = 1; + $param->{'url'} = $source; + + my $header = AttrVal($name, 'httpHeader', ''); + if ($header) { + $header =~ s/(\[.+?\])/(exists($hash->{'CONFIG'}->{'SECRET'}->{substr($1,1,length($1)-2)}))?${$hash->{'CONFIG'}->{'SECRET'}->{substr($1,1,length($1)-2)}}:$1/eg; + }; + $header .= "\r\nAccept: application/json" unless ($header =~ m'Accept: application/json'); + $param->{'header'} = $header; + $param->{'loglevel'} = AttrVal($name, 'verbose', 3); + $param->{'timeout'} = AttrVal($name, 'httpTimeout', 30); + HttpUtils_NonblockingGet($param); + return; +}; + +sub JsonMod_ApiResponse { + my ($param, $err, $data) = @_; + my $hash = $param->{'hash'}; + + # cron settings changed while doing request. discard silently + return if ($param->{'cron'} ne $hash->{'CONFIG'}->{'CRON'}); + # check for error + # TODO + $hash->{'CONFIG'}->{'IN_REQUEST'} = 0; + + $hash->{'API_LAST_RES'} = Time::HiRes::time(); + $hash->{'SOURCE'} = sprintf('%s (%s)', $param->{'url'} //= '', $param->{'code'} //= ''); + + my sub doError { + my ($msg) = @_; + $hash->{'API__LAST_MSG'} = $msg; + my $next = Time::HiRes::time() + 600; + $hash->{'API__NEXT_REQ'} = $next; + return InternalTimer($next, \&JsonMod_ApiRequest, $hash); + }; + + if ($err) { + JsonMod_Logger($hash, 2, 'http request error: %s', $err); + return doError($err); + }; + + my $rs = JsonMod::JSON::StreamReader->new()->parse($data); + if (not $rs or ((ref($rs) ne 'HASH') and ref($rs) ne 'ARRAY')) { + return doError('invalid server response'); + }; + + #my $mu = Memory::Usage->new(); + #$mu->record('before'); + JsonMod_DoReadings($hash, $rs); + #$mu->record('after'); + #$mu->dump(); + + return; +}; + +sub JsonMod_Logger { + my ($hash, $verbose, $message, @args) = @_; + my $name = $hash->{'NAME'}; + no warnings qw( redundant missing ); + Log3 ($name, $verbose, sprintf('[%s] '.$message, $name, @args)); + return; +}; + + +############################################################################### +# credits to David Oswald +# http://cpansearch.perl.org/src/DAVIDO/JSON-Tiny-0.58/lib/JSON/Tiny.pm +package JsonMod::JSON::StreamWriter; + +use strict; +use warnings; +use utf8; +use B; + +my ($escape, $reverse); + +BEGIN { + eval "use JSON::XS;1;" or do { + if (not $main::_JSON_PP_WARN) { + main::Log3 (undef, 3, sprintf('json [%s] is PP. Consider installing JSON::XS', __PACKAGE__)); + $main::_JSON_PP_WARN = 1; + }; + }; +}; + +BEGIN { + %{$escape} = ( + '"' => '"', + '\\' => '\\', + '/' => '/', + 'b' => "\x08", + 'f' => "\x0c", + 'n' => "\x0a", + 'r' => "\x0d", + 't' => "\x09", + 'u2028' => "\x{2028}", + 'u2029' => "\x{2029}" + ); + %{$reverse} = map { $escape->{$_} => "\\$_" } keys %{$escape}; + for(0x00 .. 0x1f) { + my $packed = pack 'C', $_; + $reverse->{$packed} = sprintf '\u%.4X', $_ unless defined $reverse->{$packed}; + }; +}; + +sub new { + my $class = shift; + my $self = {}; + bless $self, $class; + return $self; +}; + +sub parse { + my ($self, $data) = @_; + my $stream; + + # use JSON::XS if available + my $xs = eval 'JSON::XS::encoode_json($data)'; + return $xs if ($xs); + + if (my $ref = ref $data) { + use Encode; + return Encode::encode_utf8($self->addValue($data)); + }; +}; + +sub addValue { + my ($self, $data) = @_; + if (my $ref = ref $data) { + return $self->addONode($data) if ($ref eq 'HASH'); + return $self->addANode($data) if ($ref eq 'ARRAY'); + }; + return 'null' unless defined $data; + return $data + if B::svref_2object(\$data)->FLAGS & (B::SVp_IOK | B::SVp_NOK) + # filter out "upgraded" strings whose numeric form doesn't strictly match + && 0 + $data eq $data + # filter out inf and nan + && $data * 0 == 0; + # String + return $self->addString($data); +}; + +sub addString { + my ($self, $str) = @_; + $str =~ s!([\x00-\x1f\x{2028}\x{2029}\\"/])!$reverse->{$1}!gs; + return "\"$str\""; +}; + +sub addONode { + my ($self, $object) = @_; + my @pairs = map { $self->addString($_) . ':' . $self->addValue($object->{$_}) } + sort keys %$object; + return '{' . join(',', @pairs) . '}'; +}; + +sub addANode { + my ($self, $array) = @_; + return '[' . join(',', map { $self->addValue($_) } @{$array}) . ']'; +}; + +# static, sanitize a json message + +############################################################################### +# credits to David Oswald +# http://cpansearch.perl.org/src/DAVIDO/JSON-Tiny-0.58/lib/JSON/Tiny.pm +package JsonMod::JSON::StreamReader; + +use strict; +use warnings; +use utf8; + +BEGIN { + eval "use JSON::XS;1;" or do { + if (not $main::_JSON_PP_WARN) { + main::Log3 (undef, 3, sprintf('json [%s] is PP. Consider installing JSON::XS', __PACKAGE__)); + $main::_JSON_PP_WARN = 1; + }; + }; +}; + + +sub new { + my $class = shift; + my $self = {}; + bless $self, $class; + return $self; +}; + +sub parse { + my ($self, $in) = @_; + my $TRUE = 1; + my $FALSE = 0; + + local *exception = sub { + my ($e) = @_; + # Leading whitespace + m/\G[\x20\x09\x0a\x0d]*/gc; + # Context + my $context = 'Malformed JSON: ' . shift; + if (m/\G\z/gc) { + $context .= ' before end of data'; + } else { + my @lines = split "\n", substr($_, 0, pos); + $context .= ' at line ' . @lines . ', offset ' . length(pop @lines || ''); + }; + die "$context"; + }; + + local *_decode_string = sub { + my $pos = pos; + + # Extract string with escaped characters + m!\G((?:(?:[^\x00-\x1f\\"]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4})){0,32766})*)!gc; # segfault on 5.8.x in t/20-mojo-json.t + my $str = $1; + + # Invalid character + unless (m/\G"/gc) { #" + exception('Unexpected character or invalid escape while parsing string') + if m/\G[\x00-\x1f\\]/; + exception('Unterminated string'); + }; + + # Unescape popular characters + if (index($str, '\\u') < 0) { + #no warnings; + $str =~ s!\\(["\\/bfnrt])!$self->{'ESCAPE'}->{$1}!gs; + return $str; + }; + + # Unescape everything else + my $buffer = ''; + while ($str =~ m/\G([^\\]*)\\(?:([^u])|u(.{4}))/gc) { + $buffer .= $1; + # Popular character + if ($2) { + $buffer .= $self->{'ESCAPE'}->{$2}; + } else { # Escaped + my $ord = hex $3; + # Surrogate pair + if (($ord & 0xf800) == 0xd800) { + # High surrogate + ($ord & 0xfc00) == 0xd800 + or pos($_) = $pos + pos($str), exception('Missing high-surrogate'); + # Low surrogate + $str =~ m/\G\\u([Dd][C-Fc-f]..)/gc + or pos($_) = $pos + pos($str), exception('Missing low-surrogate'); + $ord = 0x10000 + ($ord - 0xd800) * 0x400 + (hex($1) - 0xdc00); + }; + # Character + $buffer .= pack 'U', $ord; + }; + }; + # The rest + return $buffer . substr $str, pos $str, length $str; + }; + + local *_decode_object = sub { + my %hash; + until (m/\G[\x20\x09\x0a\x0d]*\}/gc) { + # Quote + m/\G[\x20\x09\x0a\x0d]*"/gc + or exception('Expected string while parsing object'); + # Key + my $key = _decode_string(); + # Colon + m/\G[\x20\x09\x0a\x0d]*:/gc + or exception('Expected colon while parsing object'); + # Value + $hash{$key} = _decode_value(); + # Separator + redo if m/\G[\x20\x09\x0a\x0d]*,/gc; + # End + last if m/\G[\x20\x09\x0a\x0d]*\}/gc; + # Invalid character + exception('Expected comma or right curly bracket while parsing object'); + }; + return \%hash; + }; + + local *_decode_array = sub { + my @array; + until (m/\G[\x20\x09\x0a\x0d]*\]/gc) { + # Value + push @array, _decode_value(); + # Separator + redo if m/\G[\x20\x09\x0a\x0d]*,/gc; + # End + last if m/\G[\x20\x09\x0a\x0d]*\]/gc; + # Invalid character + exception('Expected comma or right square bracket while parsing array'); + }; + return \@array; + }; + + local *_decode_value = sub { + # Leading whitespace + m/\G[\x20\x09\x0a\x0d]*/gc; + # String + return _decode_string() if m/\G"/gc; + # Object + return _decode_object() if m/\G\{/gc; + # Array + return _decode_array() if m/\G\[/gc; + # Number + # jh: failed with 0123 + #my ($i) = /\G([-]?(?:0(?!\d)|[1-9][0-9]*)(?:\.[0-9]*)?(?:[eE][+-]?[0-9]+)?)/gc; + my ($i) = /\G(?=.)([+-]?([0-9]*)(\.([0-9]+))?)([eE][+-]?\d+)?/gc; + return 0 + $i if defined $i; + # True + { no warnings; + return $TRUE if m/\Gtrue/gc; + # False + return $FALSE if m/\Gfalse/gc; + }; + # Null + return undef if m/\Gnull/gc; ## no critic (return) + # Invalid character + exception('Expected string, array, object, number, boolean or null'); + }; + + local *_decode = sub { + my $valueref = shift; + eval { + # Missing input + die "Missing or empty input\n" unless length( local $_ = shift ); + # UTF-8 + $_ = eval { Encode::decode('UTF-8', $_, 1) } unless shift; + die "Input is not UTF-8 encoded\n" unless defined $_; + # Value + $$valueref = _decode_value(); + # Leftover data + return m/\G[\x20\x09\x0a\x0d]*\z/gc || exception('Unexpected data'); + } ? return undef : chomp $@; + return $@; + }; + + # use JSON::XS if available + my $xs = eval 'JSON::XS::decode_json($in)'; + return $xs if ($xs); + + my $err = _decode(\my $value, $in, 1); + return defined $err ? $err : $value; +}; + +# https://github.com/json-path/JsonPath +# https://support.smartbear.com/alertsite/docs/monitors/api/endpoint/jsonpath.html#examples + +package JsonMod::JSON::Path; + +use strict; +use warnings; +use utf8; + +sub new { + my ($class, $o) = @_; + my $self = bless {}, $class; + $self->{'root'} = JsonMod::JSON::Path::Node->new($o); + return $self; +}; + +# valid: +# $.. +# $. +# $[property] +# property +# invalid ubt accepted: +# ..property +sub get { + my ($self, $path) = @_; + my $query = JsonMod::JSON::Path::Query->new(); + #print "get $path\n"; + $path =~ s/^\$//; + $self->{'root'}->get($path, '$', $query); + return $query; +}; + +sub DESTROY { + my ($self) = @_; + #print "DESTROY $self\n"; + $self->{'root'}->release() if defined($self->{'root'}); + delete $self->{'root'}; +}; + +package JsonMod::JSON::Path::Node; + +use strict; +use warnings; +use utf8; +use Text::Balanced qw ( extract_codeblock extract_delimited ); + +sub new { + my ($class, $o, $root) = @_; + + my $t = ref($o); + if ($t eq 'HASH') { + return JsonMod::JSON::Path::HNode->new($o, $root); + } elsif ($t eq 'ARRAY') { + return JsonMod::JSON::Path::ANode->new($o, $root); + } elsif ($t eq '') { + return JsonMod::JSON::Path::VNode->new($o, $root); + }; +}; + +sub getNextProperty { + my ($self, $path) = @_; + + my ($property, $deep); + $deep = $path =~ s/^\.\.//; + $path =~ s/^([^\.])/\.$1/; + ($path =~ s/^\.([^\[\.]+)// and $property = $1); # .property + if (not defined($property)) { + $property = extract_codeblock($path, '[]', '\.') and + $property = substr($property, 1, (length($property)-2)); + if (defined($property) and ord($property) eq ord(qw ( ' ))) { + $property = extract_delimited($property, qw ( ' )) + and $property = substr($property, 1, (length($property)-2)); + }; + }; + return ($path, $property, $deep); +}; + +sub addRootNode { + my ($self, $o, $root) = @_; + if (not $root) { + $self->{'root'} = $self; + } else { + $self->{'root'} = $root; + }; + return $self; +}; + +sub release { + my ($self) = @_; + if (ref($self->{'child'}) eq 'HASH') { + foreach my $k (keys %{$self->{'child'}}) { + $self->{'child'}->{$k}->release() if defined($self->{'child'}->{$k}); + delete $self->{'child'}->{$k}; + }; + }; + delete $self->{'root'}; +}; + +sub DESTROY { + my ($self) = @_; + #print "DESTROY $self\n"; +}; + +package JsonMod::JSON::Path::HNode; + +use strict; +use warnings; +use utf8; +use parent -norequire, qw( JsonMod::JSON::Path::Node ); + +sub new { + my ($class, $o, $root) = @_; + + my $self = bless {}, $class; + #print "HNode $self\n"; + $self->addRootNode($o, $root); + + foreach my $k (keys %{$o}) { + $self->{'child'}->{$k} = JsonMod::JSON::Path::Node->new($o->{$k}, $self->{'root'}); + }; + + return $self; +}; + +sub get { + my ($self, $path, $normalized, $query) = @_; + my ($property, $deep); + #print "hash1 [$path] [$property] [$normalized]\n"; + ($path, $property, $deep) = $self->getNextProperty($path); + #print "hash2 [$path] [$property] [$normalized]\n"; + + if ((ord($property) eq ord('*')) or $deep) { + my @childList = keys (%{$self->{'child'}}); + foreach my $child (@childList) { + $self->getSingle($child, $property, $deep, $path, $normalized, $query); + }; + } else { + $self->getSingle($property, $property, $deep, $path, $normalized, $query); + }; +}; + +sub getSingle { + my ($self, $node, $property, $deep, $path, $normalized, $query) = @_; + #print "hash single: $node, $property, $deep, $path, $normalized\n"; + + #$path = "..$property$path" if $deep; + if ((ord($property) eq ord('*')) or (($node eq $property) and exists($self->{'child'}->{$node}))) { + if (not $path) { + #print "hash result $normalized.[$node]\n"; + $query->addResult($normalized."[$node]", $self->{'child'}->{$node}->getValue()); + }; + #$path = "..$property$path" if $deep; + if ($path and + (not ref($self->{'child'}->{$node}) eq 'JsonMod::JSON::Path::VNode')) { + $self->{'child'}->{$node}->get($path, $normalized."[$node]", $query); + }; + }; + if ($deep) { #and (not ref($self->{'child'}->{$node}) eq 'JsonMod::JSON::Path::VNode')) { + $path = "..$property$path"; + $self->{'child'}->{$node}->get($path, $normalized."[$node]", $query); + }; +}; + +sub getValue { + my ($self) = @_; + my $val = {}; + foreach my $c (keys %{$self->{'child'}}) { + $val->{$c} = $self->{'child'}->{$c}->getValue(); + } + return $val; +}; + +package JsonMod::JSON::Path::ANode; + +use strict; +use warnings; +use utf8; +use parent -norequire, qw( JsonMod::JSON::Path::Node ); + +sub new { + my ($class, $o, $root) = @_; + + my $self = bless {}, $class; + #print "ANode $self\n"; + $self->addRootNode($o, $root); + + for my $i (0 .. scalar(@{$o}) -1) { + $self->{'child'}->{$i} = JsonMod::JSON::Path::Node->new($o->[$i], $self->{'root'}); + }; + + return $self; +}; + +sub get { + my ($self, $path, $normalized, $query) = @_; + my ($property, $deep); + ($path, $property, $deep) = $self->getNextProperty($path); + + if (ord($property) eq ord('?')) { + my $filter = JsonMod::JSON::Path::Query::Filter->new($self)->get($property); + foreach my $child (sort { $a <=> $b } @{$filter}) { + $self->getSingle($child, $child, $deep, $path, $normalized, $query); + #$self->{'child'}->{$child}->get($path, $normalized, $query); + }; + } elsif ((ord($property) eq ord('*')) or $deep) { + my @childList = sort { $a <=> $b } keys (%{$self->{'child'}}); + foreach my $child (@childList) { + $self->getSingle($child, $property, $deep, $path, $normalized, $query); + }; + } elsif ($property =~ /^\d+$/) { + $self->getSingle($property, $property, $deep, $path, $normalized, $query); + } else { + die ("JsonPath filter property $property failure"); + }; +}; + +sub getSingle { + my ($self, $node, $property, $deep, $path, $normalized, $query) = @_; + #print "array single: $node, $property, $deep, $path, $normalized\n"; + + #$path = "..$property$path" if $deep; + if ((ord($property) eq ord('*')) or (($node eq $property) and exists($self->{'child'}->{$node}))) { + if (not $path) { + #print "array result $normalized.[$node]\n"; + $query->addResult($normalized."[$node]", $self->{'child'}->{$node}->getValue()); + }; + #$path = "..$property$path" if $deep; + if ($path and + (not ref($self->{'child'}->{$node}) eq 'JsonMod::JSON::Path::VNode')) { + $self->{'child'}->{$node}->get($path, $normalized."[$node]", $query); + }; + }; + if ($deep) { #and (not ref($self->{'child'}->{$node}) eq 'JsonMod::JSON::Path::VNode')) { + $path = "..$property$path"; + $self->{'child'}->{$node}->get($path, $normalized."[$node]", $query); + }; +}; + +sub getValue { + my ($self) = @_; + my $val = []; + my @childList = sort { $a <=> $b } keys (%{$self->{'child'}}); + foreach my $c (@childList) { + push @{$val}, $self->{'child'}->{$c}->getValue(); + } + return $val; +}; + +package JsonMod::JSON::Path::VNode; + +use strict; +use warnings; +use utf8; +use parent -norequire, qw( JsonMod::JSON::Path::Node ); + +sub new { + my ($class, $o, $root) = @_; + + my $self = bless {}, $class; + #print "VNode $self\n"; + $self->addRootNode($o, $root); + + if (not $root) { + $root = $self->{'root'} = $o; + } else { + $self->{'root'} = $root; + }; + $self->{'child'} = $o; + return $self; +}; + +sub get { + my ($self, $path, $normalized) = @_; + my ($property, $deep); + ($path, $property, $deep) = $self->getNextProperty($path); +}; + +sub getValue { + my ($self) = @_; + return $self->{'child'}; +}; + +package JsonMod::JSON::Path::Query; + +use strict; +use warnings; +use utf8; + +sub new { + my ($class) = @_; + my $self = bless {}, $class; + $self->{'nList'} = []; + $self->{'vList'} = []; + return $self; +}; + +sub addResult { + my ($self, $normalized, $value) = @_; + push @{$self->{'nList'}}, $normalized; + push @{$self->{'vList'}}, $value; +}; + +sub getResultNormalized { + my ($self) = @_; + foreach my $e (@{$self->{'nList'}}) { + print "$e\n"; + }; + +}; + +sub getResultValue { + my ($self) = @_; + return $self->{'vList'}; +}; + +sub getResultNormVal { + my ($self) = @_; + for my $i (0 .. scalar(@{$self->{'vList'}}) -1) { + print "$self->{'nList'}->[$i]\t$self->{'vList'}->[$i]\n"; + }; +}; + +sub getResultList { + my ($self) = @_; + my $result = []; + for my $i (0 .. scalar(@{$self->{'vList'}}) -1) { + push @{$result}, [$self->{'nList'}->[$i], $self->{'vList'}->[$i]]; + }; + return $result; +}; + +package JsonMod::JSON::Path::Query::Filter; + +use strict; +use warnings; +use utf8; +use List::Util qw( any ); +use Text::Balanced qw ( extract_codeblock extract_delimited ); + +sub new { + my ($class, $o) = @_; + my $self = bless {}, $class; + $self->{'nList'} = []; + $self->{'vList'} = []; + $self->{'node'} = $o; + return $self; +}; + +sub get { + my ($self, $filterText) = @_; + my $filter; + $filter = extract_codeblock($filterText, '()', '\?') + and $filter = substr($filter, 1, (length($filter)-2)); + + my ($delim, $list, $idx) = (0, 0, 0); + my @parts; + foreach my $c (split '', $filter) { + $delim ^= 1 if (ord($c) == ord(q{'})); + $list += 1 if (ord($c) == ord('[') and $delim == 0); + $list -= 1 if (ord($c) == ord(']') and $delim == 0); + die('unbalanced square brackets in JsonPath filter: '.$filterText) if ($list < 0); + $idx++ if (ord($c) == ord(' ') and $delim == 0 and $list == 0); + $parts[$idx] .= $c if (ord($c) != ord(' ') or $list != 0 or $delim == 1); + }; + die('unbalanced square brackets in JsonPath filter: '.$filterText) if ($list != 0); + return $self->filter($parts[0], $parts[1], $parts[2]); + +}; + +sub filter { + my ($self, $left, $operater, $right) = @_; + + my $result = []; + + # fn ref as test for: numeric, string, list + my ($a, $b, @a, @b); + my $dispatch = { + '==' => [sub {$a == $b}, sub {$a eq $b}, undef], + '!=' => [sub {$a != $b}, sub {$a ne $b}, undef], + '<' => [sub {$a < $b}, sub {$a lt $b}, undef], + '<=' => [sub {$a <= $b}, sub {$a le $b}, undef], + '>' => [sub {$a > $b}, sub {$a gt $b}, undef], + '>=' => [sub {$a >= $b}, sub {$a ge $b}, undef], + 'in' => [undef, undef, sub {any {$_ eq $a} @b}], + }; + + # todo: test if right is filter!!! + + # right type == numeric, string, list / operater as string / function pointer + my ($fnt, $fn); + ($right =~ m/([+-]?\d+(?:[,.]\d+)?)/ and $fnt = 0) or # numeric + ($right =~ m/^(?:['](.*)['])$/ and $fnt = 1) or # string + ($right =~ m/^(?:[\[](.*)[\]])$/ and $fnt = 2); # list + $right = $1 if (defined($fnt)); + $fn = exists($dispatch->{$operater})?$dispatch->{$operater}->[$fnt]:undef; + if ($fn) { + # run query + my $filterpath = $left; + my $queryNode; + if ($filterpath =~ s/^([\$\@])\./[*]/) { + $queryNode = $self->{'node'} if ($1 eq '@'); + $queryNode = $self->{'node'}->{'root'} if ($1 eq '$'); + } else { + die("JsonPath filter '$left' must start with \@. or \$."); + }; + my $filter = JsonMod::JSON::Path::Query->new(); + my $fltNormalized = ''; # relative to actual node + $queryNode->get($filterpath, $fltNormalized, $filter); + my $list = $filter->getResultList(); + + # numeric or string + if ($fnt == 0 or $fnt == 1) { + foreach my $e (@{$list}) { + $a = $e->[1]; # -> val + $b = $right; + if ($fn->()) { # call the test + my $r = extract_codeblock($e->[0], '[]'); + push @{$result}, substr($r, 1, length($r) - 2); # remove [] + }; + }; + # list + } elsif ($fnt == 2) { + foreach (split /,/, $right) { + s/^\s*'|^\s+|'\s+|'\s*$//g; + push @b, $_; + }; + foreach my $e (@{$list}) { + $a = $e->[1]; # -> val + if ($fn->()) { # call the test + my $r = extract_codeblock($e->[0], '[]'); + push @{$result}, substr($r, 1, length($r) - 2); # remove [] + }; + }; + }; + }; + + return $result; +}; + +sub DESTROY { + my ($self) = @_; + delete $self->{'node'}; +}; + +package JsonMod::Cron; + +use strict; +use warnings; +use utf8; +use Time::Local qw ( timelocal ); + +# static and helper +sub normalizeTime { + my ($m, $h, $d) = @_; + $d //= 0; + if ($m > 59) { $h += int($m / 60); $m %= 60; }; + if ($h > 23) { $d += int($h / 24); $h %= 24; }; + return ($m, $h, $d); +}; + +sub normalizeDate { + my ($d, $m, $y, $o) = @_; + $o //= 0; + my $time = timelocal(0, 0, 12, $d, $m -1, $y -1900); + $time += $o * 86400; + my @t = localtime($time); + # plus DST, wday (SUN=0..6), yday (0..364|5) + return ($t[3], $t[4] +1, $t[5] +1900, $t[8], $t[6], $t[7]); +}; + +# class +sub new { + my ($class) = @_; + my $self = {}; + + bless $self, $class; + return $self; +}; + +sub setCron { + my ($self, $cron) = @_; + @{$self->{'CRONLIST'}} = split / /, $cron //= ''; + return if (scalar @{$self->{'CRONLIST'}} != 5); + +}; + +sub parseMinuteEntry { + my ($self, $in, $now) = @_; + my ($res, $start, $stop, $step); + + ($step) = ($in =~ m/\/([0-9]|[0-5][0-9])$/); + ($start, $stop) = ($in =~ m/^([*]|[0-9]|[0-5][0-9])(?:-([0-9]|[0-5][0-9]))?(?:\/(?:[0-9]|[0-5][0-9]))?$/); + return if (not defined($start) or ($start eq '*' and defined($stop))); # syntax error + + $stop = (defined($step) or ($start eq '*'))?59:$start if (not defined($stop)); + $start = 0 if $start eq '*'; + return if ($start > $stop); # syntax error + return $start if ($now < $start); # literal start + + $res = $step //= 1; + $res = $res - (((($now - $start) % 60) + $res) % $res); + $res = $now + $res; + + return $start + 60 if ($res > $stop); # carry over + return $res; # regular next +}; + +sub parseHourEntry { + my ($self, $in, $now) = @_; + my ($res, $start, $stop, $step); + + ($step) = ($in =~ m/\/([0-9]|[0,1][0-9]|2[0-3])$/); + ($start, $stop) = ($in =~ m/^([*]|[0-9]|[0,1][0-9]|2[0-3])(?:-([0-9]|[0,1][0-9]|2[0-3]))?(?:\/(?:[*]|[0-9]|[0,1][0-9]|2[0-3]))?$/); + return if (not defined($start) or ($start eq '*' and defined($stop))); # syntax error + + $stop = (defined($step) or ($start eq '*'))?23:$start if (not defined($stop)); + $start = 0 if $start eq '*'; + return if ($start > $stop); # syntax error + return $start if ($now < $start); # literal start + + $res = $step //= 1; + $res = ($now - $start) % $res; + + return $now if ($res == 0) and ($now <= $stop); # current hour + $res = $now + $step - $res; + return $start + 24 if ($res > $stop); # carry over + return $res; # regular next +}; + +sub parseDateEntry { + my ($self, $in, $now) = @_; + my ($res, $start, $stop, $step); + + ($step) = ($in =~ m/\/([0-9]|[0-2][0-9]|3[0,1])$/); + ($start, $stop) = ($in =~ m/^([*]|[0-9]|[0-2][0-9]|3[0,1])(?:-([0-9]|[0-2][0-9]|3[0,1]))?(?:\/(?:[*]|[0-9]|[0-2][0-9]|3[0,1]))?$/); + return if (not defined($start) or ($start eq '*' and defined($stop))); # syntax error + + $stop = (defined($step) or ($start eq '*'))?31:$start if (not defined($stop)); + $start = 1 if $start eq '*'; + return if ($start > $stop); # syntax error + return $start if ($now < $start); # literal start + + $res = $step //= 1; + $res = ($now - $start) % $res; + + return $now if ($res == 0) and ($now <= $stop); # current + $res = $now + $step - $res; + return $start + 32 if ($res > $stop); # carry over + return $res; # regular next +}; + +sub next { + my ($self, $cron, @t) = @_; + + my $inDay = sprintf('%04d%02d%02d', $t[5], $t[4], $t[3]); + my ($cronMin, $cronHour, $cronDay, $cronMonth, $cronWeekDay) = split / /, $cron; + my ($time, $dst, $weekday); + + # m h d(carry) + $time = $self->nextTime($t[1], $t[2], $cronMin, $cronHour); + return if (not $time); + ($t[3], $t[4], $t[5], $dst, $weekday) = normalizeDate($t[3], $t[4], $t[5], $time->[2]); + my $calcDay = sprintf('%04d%02d%02d', $t[5], $t[4], $t[3]); + + # date unchanged and known + if ($calcDay eq $inDay) { + return ($time->[0], $time->[1], $t[3], $t[4], $t[5], $dst); + }; + + # m h d(carry) + $time = $self->nextTime(0, 0, $cronMin, $cronHour); + #($t[3], $t[4], $t[5], $dst, $weekday) = normalizeDate($t[3], $t[4], $t[5], $time->[2]); + + # yyyy mm dd + my $date = $self->nextDate($t[3], $t[4], $t[5], $cronDay, $cronMonth); + return if (not $date); + ($t[3], $t[4], $t[5], $dst, $weekday) = normalizeDate($date->[2], $date->[1], $date->[0]); + + return ($time->[0], $time->[1], $t[3], $t[4], $t[5], $dst); +}; + +# test if valid cron expression +sub validate { + my ($self, $cron) = @_; + my ($cronMin, $cronHour, $cronDay, $cronMonth, $cronWeekDay) = split / /, $cron; + my $time = $self->nextTime(0, 0, $cronMin, $cronHour); + my $date = $self->nextDate(2020, 1, 1, $cronDay, $cronMonth); + if (defined($time) and defined($date)) { + return 1; + } else { + return; + }; +}; + +# min = time: actual minute +# hour = time: actual hour +sub nextTime { + my ($self, $min, $hour, $cronMin, $cronHour) = @_; + + my $calcMin; + my $calcHour; + my $calcDay = 0; + + foreach my $cronMinEntry (split /,/, $cronMin) { + my $e = $self->parseMinuteEntry($cronMinEntry, $min); + return if not defined($e); # syntax error + if ((not defined($calcMin) and defined($e)) or ($e < $calcMin)) { + $calcMin = $e; + }; + }; + ($calcMin, $hour, $calcDay) = normalizeTime($calcMin, $hour, $calcDay); + + foreach my $cronHourEntry (split /,/, $cronHour) { + my $e = $self->parseHourEntry($cronHourEntry, $hour); + return if not defined($e); # syntax error + if ((not defined($calcHour) and defined($e)) or ($e < $calcHour)) { + $calcHour = $e; + }; + }; + my (@time) = normalizeTime($calcMin, $calcHour, $calcDay); + return \@time; + +}; + +sub nextDate { + my ($self, $day, $month, $year, $cronDay, $cronMonth) = @_; + + my $dates = $self->listDates($day, $month, $year, $cronDay, $cronMonth); + my $result; + foreach (@{$dates}) { + if ((not defined($result) and defined($_)) or ($_ and ($_ < $result))) { + $result = $_; + }; + }; + return if (not defined($result)); + my (@date) = ($result =~ m/^(\d{4})(\d{2})(\d{2})$/); + return \@date; +}; + +sub listDates { + my ($self, $day, $month, $year, $cronDay, $cronMonth) = @_; + my @result; + + #return [] if ($self->{R}++ > 25); + + my sub daysOfMonth { + my ($m, $y) = @_; + my (@d) = (0,31,28,31,30,31,30,31,31,30,31,30,31); + # leapyear + $d[2] = 29 if (((($y % 4) == 0) and (($y % 100) != 0)) or (($y % 400) == 0)); + return ($d[$m]); + }; + + foreach my $cronDayEntry (split /,/, $cronDay) { + foreach my $cronMonthEntry (split /,/, $cronMonth) { + # impossible cron would recurse forever: [31 2 * * *] / [31 9/2 * * *] + my $invalid = 1; + if ((my ($fuseDay) = ($cronDayEntry =~ m/^(\d{1,2})/)) and + (my ($fuseMonth, $fuseMonthStep) = ($cronMonthEntry =~ m/^(\d{1,2})(?:\/(\d{1,2}))*/))) { + #print "FUSE $fuseDay, $fuseMonth, $fuseMonthStep\n"; + for (my $i = $fuseMonth; $i <= 12 and $invalid; $i += $fuseMonthStep //= 12) { + $invalid = 0 if (daysOfMonth($fuseMonth, 2000) >= $fuseDay); # 2000 is leapyear + }; + if ($invalid) { + push @result, (); + next; + }; + }; + my $calcDay = $self->parseDateEntry($cronDayEntry, $day); + my $calcMonth = $self->parseDateEntry($cronMonthEntry, $month); + my $calcYear = $year; + #printf "Test: D:%s, M:%s against %s-%s -> %s-%s-%s\n", $cronDayEntry, $cronMonthEntry, $day, $month, $calcDay, $calcMonth, $calcYear; + if (defined($calcDay) and defined($calcMonth)) { + #$doy = isValid($testM, $testMd); + if (($calcDay == $day) and ($calcMonth == $month)) { + #printf "RETURN: D:%s, M:%s against %s-%s-%s -> %s-%s-%s\n", $cronDayEntry, $cronMonthEntry, $day, $month, $year, $calcDay, $calcMonth, $calcYear; + push @result, sprintf('%04d%02d%02d', $calcYear, $calcMonth, $calcDay); + } else { + if ($calcMonth > 12) { + $calcMonth -= ($calcMonth == 13)?12:32; + $calcYear++; + }; + if ($calcDay > daysOfMonth($calcMonth, $calcYear)) { + $calcMonth++ if ($calcMonth == $month); + $calcDay = 1; + }; + push @result, @{ $self->listDates($calcDay, $calcMonth, $calcYear, $cronDayEntry, $cronMonthEntry) }; + }; + } else { + return []; # syntax error + }; + }; + }; + return \@result; +}; + +1; + +=pod +=item helper +=item summary provides a generic way to parse and display json source +=item summary_DE JSON Quellen parsen und und verwenden +=begin html + + +

JsonMod

+ + +=end html + +=cut \ No newline at end of file