Group
Extension

Net-Z3950-FOLIO/lib/Net/Z3950/FOLIO/Session.pm

package Net::Z3950::FOLIO::Session;

use strict;
use warnings;

use DateTime;
use Cpanel::JSON::XS qw(decode_json encode_json);
use Net::Z3950::FOLIO::Config;
use Net::Z3950::FOLIO::ResultSet;


sub _throw { return Net::Z3950::FOLIO::_throw(@_); }


sub new {
    my $class = shift();
    my($ghandle, $name) = @_;

    my $ua = new LWP::UserAgent();
    my $jar = HTTP::Cookies->new();
    $ua->cookie_jar($jar);
    $ua->agent("z2folio $Net::Z3950::FOLIO::VERSION");

    return bless {
	ghandle => $ghandle,
	ua => $ua,
	name => $name,
	resultsets => {}, # indexed by setname
    }, $class;
}


sub reloadConfigFile {
    my $this = shift();
    my $ghandle = $this->{ghandle};

    $this->{cfg} = new Net::Z3950::FOLIO::Config($ghandle->{cfgbase}, split(/\|/, $this->{name}));
}


sub login {
    my $this = shift(); 
    my($user, $pass) = @_;

    my $cfg = $this->{cfg};
    my $login = $cfg->{login} || {};
    my $username = $user || $login->{username};
    my $password = $pass || $login->{password};
    _throw(1014, "credentials not supplied")
	if !defined $username || !defined $password;

    my $url = $cfg->{okapi}->{url} . '/authn/login-with-expiry';
    my $req = $this->_makeHTTPRequest(POST => $url);
    $req->content(qq[{ "username": "$username", "password": "$password" }]);
    # warn "req=", $req->content();
    my $res = $this->{ua}->request($req);
    # warn "res=", $res->content();
    _throw(1014, $res->content())
	if !$res->is_success();

    $this->_setRefreshTokenExpiration($res);
}


sub _setRefreshTokenExpiration {
    my $this = shift();    
    my($res) = @_;

    my $json = decode_json($res->content());
    my $refreshTokenEpoch = _isoStringToEpoch($json->{refreshTokenExpiration});
    my $accessTokenEpoch = _isoStringToEpoch($json->{accessTokenExpiration});
    my $minEpoch = $refreshTokenEpoch < $accessTokenEpoch ? $refreshTokenEpoch : $accessTokenEpoch;
    my $nowEpoch = DateTime->now()->epoch();
    my $secs = $minEpoch - $nowEpoch;

    # Choose when to get a new token, based on when the shorter-lived
    # of the two tokens expires. One simple option would be when half
    # of the allocated time has expired. Another would be a constant
    # time (e.g. one minute) before the expiry is due. For now, we'll
    # go with the second.
    $this->{refreshTokenExpiration} = $minEpoch - 60;
}


sub _isoStringToEpoch {
    my($isoString) = @_;
    
    # Format: 2024-07-02T16:48:56Z
    my $match = ($isoString =~ /(\d\d\d\d)-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)Z/);
    _throw(2, "Non-ISO date returned as refreshTokenExpiration: $isoString")
	if !$match;

    my($year, $mon, $mday, $hour, $min, $sec) = ($1, $2, $3, $4, $5, $6);
    my $dt = new DateTime(
	year       => $year,
	month      => $mon,
	day        => $mday,
	hour       => $hour,
	minute     => $min,
	second     => $sec,
    );

    return $dt->epoch();
}

sub maybeRefreshToken {
    my $this = shift();

    my $cj = $this->{ua}->cookie_jar();
    if ($cj->as_string()) {
	my $accessToken = $cj->as_string();
	$accessToken =~ s/.*folioAccessToken=(.*?);.*/$1/s;
	$this->{accessToken} = $accessToken;

	# We have some cookies, so initial login must have succeeded
	my $now = DateTime->now();
	my $nowEpoch = $now->epoch();
	if ($nowEpoch > $this->{refreshTokenExpiration}) {
	    warn "update required";
	    my $url = $this->{cfg}->{okapi}->{url} . '/authn/refresh';
	    my $req = $this->_makeHTTPRequest(POST => $url);
	    # No content required
	    my $res = $this->{ua}->request($req);
	    _throw(1014, $res->content())
		if !$res->is_success();

	    $this->_setRefreshTokenExpiration($res);
	}
    }
}


sub rerunSearch {
    my $this = shift();
    my($setname) = @_;

    my $cql = $this->{cql};
    my $rs = new Net::Z3950::FOLIO::ResultSet($this, $setname, $cql);
    $this->{resultsets}->{$setname} = $rs;

    my $chunkSize = $this->{cfg}->{chunkSize} || 10;
    $this->doSearch($rs, 0, $chunkSize);
    return $rs->totalCount();
}


sub doSearch {
    my $this = shift();
    my($rs, $offset, $limit) = @_;

    my $okapiCfg = $this->{cfg}->{okapi};
    my $qf = $this->{cfg}->{queryFilter};
    my $cql = $rs->{cql};
    if ($qf) {
	$cql = $cql ? "($cql) and ($qf)" : $qf;
    }
    my $sortspec = $this->{sortspec};
    if ($sortspec) {
	$cql = "($cql) sortby $sortspec";
	warn "search: added sortspec, yielding '$cql'";
    }

    my $url = $okapiCfg->{url};
    my $graphqlUrl = $okapiCfg->{graphqlUrl};
    my $req = $this->_makeHTTPRequest(POST => ($graphqlUrl || $url) . '/graphql');
    if ($graphqlUrl) {
	$req->header('X-Okapi-Url' => $url);
	# Okapi-moderated modules do not need an explicit token, but side-loaded mod-graphql does
	$req->header('X-Okapi-Token' => $this->{accessToken});
    }

    my %variables = ();
    # warn "searching for $cql";
    $variables{cql} = $cql if $cql;
    $variables{offset} = $offset if $offset;
    $variables{limit} = $limit if $limit;
    my %body = (
	query => $this->{cfg}->{graphql},
	variables => \%variables,
    );
    $req->content(encode_json(\%body));
    my $res = $this->{ua}->request($req);
    _throw(3, $res->content()) if !$res->is_success();

    my $obj = decode_json($res->content());
    # warn "result: ", Net::Z3950::FOLIO::Record::_formatJSON($obj);
    my $errors = $obj->{errors};
    _throw(1, join(', ', map { $_->{message} } @$errors)) if $errors;
    my $data = $obj->{data} or _throw(1, "no data in response");
    my $isi = ($data->{search_instances} || $data->{instance_storage_instances});
    if (!$isi) {
	_throw(1, "no instance_storage_instances in response data " . $res->content());
    }
    $rs->totalCount($isi->{totalRecords} + 0);
    $rs->insertRecords($offset, $isi->{instances});

    return $rs;
}


sub _getSRSRecords {
    my $this = shift();
    my($rs, $offset, $limit) = @_;

    my $okapiCfg = $this->{cfg}->{okapi};
    my @ids = ();
    for (my $i = 0; $i < $limit && $offset + $i < $rs->totalCount(); $i++) {
	my $rec = $rs->record($offset + $i);
	push @ids, $rec->id();
    }

    my $req = $this->_makeHTTPRequest(POST => $okapiCfg->{url} . '/source-storage/source-records?idType=INSTANCE');
    $req->content(encode_json(\@ids));
    my $res = $this->{ua}->request($req);
    my $content = $res->content();
    _throw(3, $content) if !$res->is_success();

    # warn "got content ", $content;
    my $json = decode_json($content);
    my $srs = $json->{sourceRecords};
    return map { _JSON2MARC($_->{parsedRecord}->{content}) } @$srs;
}


# We would like to use MARC::Record->new_from_json() for this (from
# MARC::File::JSON), but that uses a different JSON encoding from the
# one used for FOLIO's SRS records, so we have to do it by hand.
#
sub _JSON2MARC {
    my($content) = shift();

    my $marc = new MARC::Record();
    $marc->leader($content->{leader});
    my $fields = $content->{fields};
    my $n = @$fields;
    for (my $i = 0; $i < $n; $i++) {
	my $field = $fields->[$i];
	my @keys = keys %$field;
	warn "field #", ($i+1), " of $n has ", scalar(@keys), " fields" if @keys != 1;
	foreach my $key (@keys) {
	    my $value = $field->{$key};
	    if ($key =~ /^00/) {
		$marc->append_fields(new MARC::Field($key, $value));
	    } else {
		# *sigh* I have to gather an array of single-key hashes into one hash
		my @subfields;
		for (my $j = 0; $j < @{$value->{subfields}}; $j++) {
		    foreach my $k2 (keys %{ $value->{subfields}->[$j] }) {
			push @subfields, $k2, $value->{subfields}->[$j]->{$k2};
		    }
		}
		if (@subfields) {
		    $marc->append_fields(new MARC::Field($key, $value->{ind1}, $value->{ind2}, @subfields));
		}
	    }
	}
    }

    return $marc;
}


sub sortSpecs2CQL {
    my $this = shift();
    my($sequence) = @_;

    my @res = ();
    foreach my $item (@$sequence) {	
	push @res, $this->_singleSortSpec2CQL($item);
    }

    my $spec = join(' ', @res);
    return $spec;
}


sub _singleSortSpec2CQL {
    my $this = shift();
    my($item) = @_;
    my $indexMap = $this->{cfg}->{indexMap};

    my $set = $item->{ATTRSET};
    if ($set ne Net::Z3950::FOLIO::ATTRSET_BIB1() && lc($set) ne 'bib-1') {
	# Unknown attribute set (anything except BIB-1)
	_throw(121, $set);
    }

    my @modifiers = (
	[ missing => _translateSortParam($item->{MISSING}, 213, {
	    1 => 'missingFail',
	    2 => 'missingLow',
	})],
	[ relation => _translateSortParam($item->{RELATION}, 214, {
	    0 => 'ascending',
	    1 => 'descending',
	})],
	[ case => _translateSortParam($item->{CASE}, 215, {
	    0 => 'respectCase',
	    1 => 'ignoreCase',
        })],
    );

    my($accessPoint, $cqlIndex, $entry);
    my $attrs = $item->{SORT_ATTR};
    foreach my $attr (@$attrs) {
	my $type = $attr->{ATTR_TYPE};
	_throw(237, "sort-attribute of type $type (only 1 is supported)") if defined $type && $type != 1;

	$accessPoint = $attr->{ATTR_VALUE};
	$entry = $indexMap->{$accessPoint};
	_throw(207, "undefined sort-index $accessPoint") if !defined $entry;
	if (ref $entry) {
	    $cqlIndex = $entry->{cql};
	} else {
	    $cqlIndex = $entry;
	    $entry = undef;
	}
	last;
    }

    my $res = $cqlIndex;

    my $omitList = $entry ? $entry->{omitSortIndexModifiers} : [];
    foreach my $modifier (@modifiers) {
	my($name, $value) = @$modifier;
	if (!$omitList || ! grep { $_ eq $name } @$omitList) {
	    $res .= "/sort.$value";
	} else {
	    # warn "omitting '$name' sort-modifier for access-point $accessPoint ($cqlIndex)";
	}
    };

    return $res;
}


sub _translateSortParam {
    my($zval, $diag, $map) = @_;

    my $cqlVal = $map->{$zval};
    _throw($diag, $zval) if !$cqlVal;
    return $cqlVal;
}


sub _makeHTTPRequest() {
    my $this = shift();
    my(%args) = @_;

    my $req = new HTTP::Request(%args);
    $req->header('X-Okapi-tenant' => $this->{cfg}->{okapi}->{tenant});
    $req->header('Content-type' => 'application/json');
    $req->header('Accept' => 'application/json');
    return $req;
}


1;


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