LP#1729620 New optional feature: an OAI2 provider service.
authorLucien van Wouw <lwo@iisg.nl>
Thu, 2 Nov 2017 14:14:51 +0000 (15:14 +0100)
committerJane Sandberg <sandbergja@gmail.com>
Mon, 28 Mar 2022 02:57:21 +0000 (19:57 -0700)
This module is an opensrf service that exposes bibliographic and authority records through the OAI2 protocol.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.

Signed-off-by: Lucien van Wouw, IISG, Amsterdam.
Signed-off-by: Remington Steed <rjs7@calvin.edu>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
Signed-off-by: Galen Charlton <gmc@equinoxOLI.org>
Signed-off-by: Mike Rylander <mrylander@gmail.com>

.gitignore
Open-ILS/examples/apache_24/eg_vhost.conf.in
Open-ILS/examples/fm_IDL.xml
Open-ILS/examples/opensrf.xml.example
Open-ILS/examples/opensrf_core.xml.example
Open-ILS/src/perlmods/lib/OpenILS/Application/OAI.pm [new file with mode: 0644]
Open-ILS/src/perlmods/lib/OpenILS/WWW/OAI.pm [new file with mode: 0644]
Open-ILS/src/sql/Pg/oai.sql [new file with mode: 0644]
Open-ILS/xsl/OAI2_MARC21slim.xsl [new file with mode: 0644]
Open-ILS/xsl/OAI2_OAIDC.xsl [new file with mode: 0644]
docs/RELEASE_NOTES_NEXT/OAI2/install.adoc [new file with mode: 0644]

index 23d7d99..b49215d 100644 (file)
@@ -5,6 +5,7 @@
 *.pyc
 *.slo
 *.class
+.idea/
 
 aclocal.m4
 autom4te.cache/
index 376aca9..d62ca3b 100644 (file)
@@ -88,6 +88,15 @@ OSRFTranslatorConfig @sysconfdir@/opensrf_core.xml
     Require all granted 
 </Location>
 
+# Uncomment this section to enable the OAI2 provider service.
+#<Location /opac/extras/oai>
+#    SetHandler perl-script
+#    PerlHandler OpenILS::WWW::OAI
+#    Options +ExecCGI
+#    PerlSendHeader On
+#    Require all granted
+#</Location>
+
 # ----------------------------------------------------------------------------------
 # Replace broken cover images with a transparent GIF by default
 # ----------------------------------------------------------------------------------
index e6e4c6a..4880eee 100644 (file)
@@ -74,6 +74,28 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
 <IDL xmlns="http://opensrf.org/spec/IDL/base/v1" xmlns:idl="http://opensrf.org/spec/IDL/base/v1" xmlns:oils_persist="http://open-ils.org/spec/opensrf/IDL/persistence/v1" xmlns:oils_obj="http://open-ils.org/spec/opensrf/IDL/objects/v1" xmlns:reporter="http://open-ils.org/spec/opensrf/IDL/reporter/v1" xmlns:sr="http://open-ils.org/spec/opensrf/IDL/simple-reporter/v1" xmlns:permacrud="http://open-ils.org/spec/opensrf/IDL/permacrud/v1">
 
        <!-- Virtual classes -->
+
+       <class id="oai_biblio" controller="open-ils.cstore" oils_obj:fieldmapper="oai::biblio"
+                  oils_persist:readonly="true" reporter:core="false" reporter:label="OAI2 record list"
+                  oils_persist:tablename="oai.biblio">
+               <fields>
+                       <field reporter:label="TCN Value\OAI identifier postfix" name="tcn" reporter:datatype="number"/>
+                       <field reporter:label="Last edit date\OAI datestamp" name="datestamp" reporter:datatype="timestamp"/>
+                       <field reporter:label="Is Deleted?" name="deleted" reporter:datatype="bool"/>
+                       <field reporter:label="Setspec" name="set_spec" oils_persist:virtual="true"/>
+               </fields>
+       </class>
+       <class id="oai_authority" controller="open-ils.cstore" oils_obj:fieldmapper="oai::authority"
+                  oils_persist:readonly="true" reporter:core="false" reporter:label="OAI2 record list"
+                  oils_persist:tablename="oai.authority">
+               <fields>
+                       <field reporter:label="TCN Value\OAI identifier postfix" name="tcn" reporter:datatype="number"/>
+                       <field reporter:label="Last edit date\OAI datestamp" name="datestamp" reporter:datatype="timestamp"/>
+                       <field reporter:label="Is Deleted?" name="deleted" reporter:datatype="bool"/>
+                       <field reporter:label="Setspec" name="set_spec" oils_persist:virtual="true"/>
+               </fields>
+       </class>
+
        <class id="mups" controller="open-ils.cstore" oils_obj:fieldmapper="money::user_payment_summary" oils_persist:virtual="true" reporter:label="User Payment Summary">
                <fields>
                        <field name="usr" oils_persist:virtual="true" />
index 24e9005..33e14e7 100644 (file)
@@ -991,6 +991,116 @@ vim:et:ts=4:sw=4:
                 </app_settings>
             </open-ils.cstore>
 
+            <open-ils.oai>
+                <keepalive>5</keepalive>
+                <stateless>1</stateless>
+                <language>perl</language>
+                <implementation>OpenILS::Application::OAI</implementation>
+                <max_requests>199</max_requests>
+                <unix_config>
+                    <unix_sock>open-ils.oai_unix.sock</unix_sock>
+                    <unix_pid>open-ils.oai_unix.pid</unix_pid>
+                    <max_requests>1000</max_requests>
+                    <unix_log>open-ils.oai_unix.log</unix_log>
+                    <min_children>1</min_children>
+                    <max_children>5</max_children>
+                    <min_spare_children>1</min_spare_children>
+                    <max_spare_children>2</max_spare_children>
+                </unix_config>
+                <app_settings>
+
+                    <!-- Where necessary, override the default settings here in the app_settings element. -->
+
+                    <!-- The OAI endpoint. The domain is the name of your proxy or frontend opac website. -->
+                    <!-- <base_url>http://mydomain.org/opac/extras/oai</base_url> -->
+
+                    <!-- <repository_name>My organization(s)</repository_name> -->
+                    <!-- <admin_email>admin@mydomain.org</admin_email> -->
+
+                    <!-- The maximum number of records in a ListRecords and ListIdentifiers response. -->
+                    <!-- <max_count>50</max_count> -->
+
+                    <!-- <granularity>YYYY-MM-DDThh:mm:ss</granularity> -->
+                    <!-- <earliest_datestamp>0001-01-01</earliest_datestamp> -->
+                    <!-- <deleted_record>yes</deleted_record> -->
+                    <!-- <scheme>oai</scheme> -->
+                    <!-- <repository_identifier>mydomain.org</repository_identifier> -->
+                    <!-- <delimiter>:</delimiter> -->
+                    <!-- <sample_identifier>oai:mydomain.org:12345</sample_identifier> -->
+                    <!-- <list_sets>false</list_sets> -->
+
+                    <!--
+                    The metadataformat element contains the schema for the oai_dc and marcxml metadata formats.
+                    Each schema needs a reference to an xslt document.
+                    You can replace them with your custom xslt stylesheets.
+                    Place those in the /<openils sysdir>/var/xsl folder.
+                    You can also extend the OAI2 service further with new metadata schema.
+
+                    Bibliographic and authority records share the same stylesheet.
+                    Should you want to render them differently, use the
+                    marc:datafield[@tag='901']/marc:subfield[@code='t']
+                    value to identify the record type. -->
+
+                    <!--
+                    <metadataformat>
+                        <oai_dc>
+                            <namespace_uri>http://www.openarchives.org/OAI/2.0/oai_dc/</namespace_uri>
+                            <schema_location>http://www.openarchives.org/OAI/2.0/oai_dc.xsd</schema_location>
+                            <xslt>OAI2_OAIDC.xsl</xslt>
+                        </oai_dc>
+                        <marcxml>
+                            <namespace_uri>http://www.loc.gov/MARC21/slim</namespace_uri>
+                            <schema_location>http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd</schema_location>
+                            <xslt>OAI2_MARC21slim.xsl</xslt>
+                        </marcxml>
+                    </metadataformat> -->
+
+                    <!--
+                    You can add different schema to the metadataformat element thus:
+                        <mods>
+                            <namespace_uri>http://www.loc.gov/mods/</namespace_uri>
+                            <schema_location>http://www.loc.gov/standards/mods/mods.xsd</schema_location>
+                            <xslt>my-custom-marc2mods.xsl</xslt>
+                        </mods>
+                        <my-metadata_prefix>
+                            <namespace_uri>my-namespace_uri</namespace_uri>
+                            <schema_location>my-schema_location</schema_location>
+                            <xslt>my-marc2my-metadata.xsl</xslt>
+                        </my-metadata_prefix>
+                    -->
+
+                    <!-- Change the way the asset copy values are mapped to which subfield codes: -->
+                    <!--
+                    <copies>
+                        <a>location</a>
+                        <b>owning_lib</b>
+                        <c>callnumber</c>
+                        <d>circlib</d>
+                        <g>barcode</g>
+                        <n>status</n>
+                    </copies>
+                    -->
+                    <!-- Or add static values to the copies element like this:
+                        <z>A value that always should for example be in the 852$z</z>
+                    -->
+
+                    <!-- Accept only 852$[barcode] values that match this regular expression. E.g.
+                    <barcode_filter>^[A-Za-z0-9]+</barcode_filter>
+                    only renders 852 datafields that contain barcodes values that begin with letters and numbers.
+                    <!--
+                    <barcode_filter><barcode_filter>
+                    -->
+
+                    <!-- Accept only 852$[status] values that match this regular expression. E.g.
+                    <status_filter>^Available$</status_filter>
+                    only renders 852 datafields that contain status code values that exactly match the string 'Available'.
+                    <!--
+                    <status_filter></status_filter>
+                    -->
+
+                </app_settings>
+            </open-ils.oai>
+
             <open-ils.pcrud>
                 <keepalive>6</keepalive>
                 <migratable>1</migratable>
@@ -1364,7 +1474,8 @@ vim:et:ts=4:sw=4:
                 <appname>open-ils.storage</appname>  
                 <appname>open-ils.justintime</appname>  
                 <appname>open-ils.cstore</appname>  
-                <appname>open-ils.collections</appname>  
+                <appname>open-ils.collections</appname>
+                <appname>open-ils.oai</appname>
                 <appname>open-ils.qstore</appname>
                 <appname>open-ils.reporter</appname>  
                 <appname>open-ils.reporter-store</appname>  
index aeba4ad..3c0d366 100644 (file)
@@ -30,6 +30,7 @@ Example OpenSRF bootstrap configuration file for Evergreen
           <service>open-ils.courses</service>
           <service>open-ils.curbside</service>
           <service>open-ils.fielder</service>
+          <service>openils.oai</service>
           <service>open-ils.pcrud</service>
           <service>open-ils.permacrud</service>
           <service>open-ils.reporter</service>
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/Application/OAI.pm b/Open-ILS/src/perlmods/lib/OpenILS/Application/OAI.pm
new file mode 100644 (file)
index 0000000..c4fb61a
--- /dev/null
@@ -0,0 +1,520 @@
+# OpenILS::WWW::OAI manages OAI2 requests and responses.
+#
+# Copyright (c) 2014-2017 International Institute of Social History
+#
+# This program 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 3 of the License, or
+# (at your option) any later version.
+#
+# This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+#
+# Author: Lucien van Wouw <lwo@iisg.nl>
+
+
+package OpenILS::Application::OAI;
+use strict; use warnings;
+
+use base qw/OpenILS::Application/;
+use OpenSRF::AppSession;
+use OpenSRF::EX qw(:try);
+use MARC::Record;
+use MARC::File::XML ( BinaryEncoding => 'UTF-8' );
+use OpenSRF::Utils::SettingsClient;
+use OpenSRF::Utils::Logger qw($logger);
+use XML::LibXML;
+use XML::LibXSLT;
+
+my (
+  $_parser,
+  $_xslt,
+  %record_xslt,
+  %metarecord_xslt,
+  %holdings_data_cache,
+  %authority_browse_axis_cache,
+  %copies,
+  $barcode_filter,
+  $status_filter
+);
+
+
+sub child_init {
+
+    # set the XML parser
+    $_parser = new XML::LibXML;
+
+    # and the xslt parser
+    $_xslt = new XML::LibXSLT;
+
+    # Load the metadataformats that are configured.
+    my $metadata_format = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'}->{'metadataformat'};
+    if ( $metadata_format ) {
+        for my $schema ( keys %$metadata_format ) {
+            $logger->info('Loading schema ' . $schema) ;
+            $record_xslt{$schema}{namespace_uri}   = $metadata_format->{$schema}->{namespace_uri};
+            $record_xslt{$schema}{schema_location} = $metadata_format->{$schema}->{schema_location};
+            $record_xslt{$schema}{xslt}            = $_xslt->parse_stylesheet( $_parser->parse_file(
+                OpenSRF::Utils::SettingsClient->new->config_value( dirs => 'xsl' ) . '/' . $metadata_format->{$schema}->{xslt}
+            ) );
+        }
+    }
+
+    # Fall back on system defaults if oai_dc is not set in the configuration.
+    unless ( exists $record_xslt{oai_dc} ) {
+        $logger->info('Loading default oai_dc schema') ;
+        my $xslt = $_parser->parse_file(
+            OpenSRF::Utils::SettingsClient
+                ->new
+                ->config_value( dirs => 'xsl' ).
+            "/OAI2_OAIDC.xsl"
+        );
+        # and stash a transformer
+        $record_xslt{oai_dc}{xslt} = $_xslt->parse_stylesheet( $xslt );
+        $record_xslt{oai_dc}{namespace_uri} = 'http://www.openarchives.org/OAI/2.0/oai_dc/';
+        $record_xslt{oai_dc}{schema_location} = 'http://www.openarchives.org/OAI/2.0/oai_dc.xsd';
+    }
+
+    # If not defined, use the natural marcxml metadata setting
+    unless ( exists $record_xslt{marcxml}) {
+        $logger->info('Loading default marcxml schema') ;
+        my $xslt = $_parser->parse_file(
+            OpenSRF::Utils::SettingsClient
+                ->new
+                ->config_value( dirs => 'xsl' ).
+            "/OAI2_MARC21slim.xsl"
+        );
+        # and stash a transformer
+        $record_xslt{marcxml}{xslt} = $_xslt->parse_stylesheet( $xslt );
+        $record_xslt{marcxml}{namespace_uri} = 'http://www.loc.gov/MARC21/slim';
+        $record_xslt{marcxml}{docs} = 'http://www.loc.gov/MARC21/slim';
+        $record_xslt{marcxml}{schema_location} = 'http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd';
+    }
+
+    # Load the mapping of 852 holdings.
+    my $copies = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'}->{'copies'} ;
+    if ( $copies ) {
+        foreach my $subfield_code (keys %$copies) {
+            my $value = $copies->{$subfield_code};
+            $logger->info('Set 852 map ' . $subfield_code . '=' . $value );
+            $copies{$subfield_code} = $value;
+        }
+    } else { # if not defined, fall back on these defaults.
+        %copies = (
+            a => 'location',
+            b => 'owning_lib',
+            c => 'callnumber',
+            d => 'circlib',
+            g => 'barcode',
+            n => 'status'
+        );
+    }
+
+    # Set the barcode filter and status filter
+    $barcode_filter = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'}->{'barcode_filter'};
+    $status_filter = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'}->{'status_filter'};
+
+    return 1;
+}
+
+
+sub list_record_formats {
+
+    my @list;
+    for my $type ( keys %record_xslt ) {
+        push @list,
+            { $type =>
+                { namespace_uri   => $record_xslt{$type}{namespace_uri},
+                  docs        => $record_xslt{$type}{docs},
+                  schema_location => $record_xslt{$type}{schema_location},
+                }
+            };
+    }
+
+    return \@list;
+}
+
+__PACKAGE__->register_method(
+    method    => 'list_record_formats',
+    api_name  => 'open-ils.oai.record.formats',
+    api_level => 1,
+    argc      => 0,
+    signature =>
+    {
+        desc     => 'Returns the list of valid record formats that oai understands.',
+        'return' =>
+        {
+            desc => 'The format list.',
+            type => 'array'
+        }
+    }
+);
+
+
+sub oai_biblio_retrieve {
+
+    my $self = shift;
+    my $client = shift;
+    my $tcn = shift;
+    my $metadataPrefix = shift;
+
+    #  holdings hold an array of call numbers, which hold an array of copies
+    #  holdings => [ label: { library, [ copies: { barcode, location, status, circ_lib } ] } ]
+    my %holdings;
+
+    my $_storage = OpenSRF::AppSession->create( 'open-ils.cstore' );
+
+    # Retrieve the bibliographic record and it's copies
+    my $tree = $_storage->request(
+        "open-ils.cstore.direct.biblio.record_entry.retrieve",
+        $tcn,
+        { flesh     => 5,
+          flesh_fields  => {
+                    bre => [qw/marc edit_date call_numbers/],
+                    acn => [qw/edit_date copies owning_lib prefix suffix/],
+                    acp => [qw/edit_date location status circ_lib parts/],
+                }
+        }
+    )->gather(1);
+
+    # Create a MARC::Record object with the marc.
+    my $marc = MARC::Record->new_from_xml( $tree->marc, 'UTF8', 'XML');
+
+    # Retrieve the MFHD where we can find them.
+    my %serials;
+    if ( substr($marc->leader, 7, 1) eq 's' ) { # serial
+        my $_search = OpenSRF::AppSession->create( 'open-ils.search' );
+        my $_serials = $_search->request('open-ils.search.serial.record.bib.retrieve', $tcn, 1, 0)->gather(1);
+        my $order = 0 ;
+        for my $sre (@$_serials) {
+            if ( $sre->location ) {
+                $order++ ;
+                my @svr = split( ' -- ', $sre->location );
+                my $cn_label = $svr[-1];
+                $serials{$order}{'label'} = $cn_label ;
+                my $display = @{$sre->basic_holdings_add} ? $sre->basic_holdings_add : $sre->basic_holdings;
+                $serials{$order}{'ser'} = join(', ', @{$display});
+            }
+        }
+    }
+
+    my $edit_date = $tree->edit_date ;
+
+    # Prepare a hash of all holdings and serials
+    for my $cn (@{$tree->call_numbers}) {
+
+        next unless ( $cn->deleted eq 'f' || !$cn->deleted );
+        my $_visible = 0;
+        for my $c (@{$cn->copies}) {
+            $_visible = _cp_is_visible($cn, $c);
+            last if ( $_visible );
+        }
+        next unless $_visible;
+
+        my $cn_label = $cn->label;
+        $holdings{$cn_label}{'owning_lib'} = $cn->owning_lib->shortname;
+
+        $edit_date =  most_recent_date( $cn->edit_date, $edit_date );
+
+        for my $cp (@{$cn->copies}) {
+
+            next unless _cp_is_visible($cn, $cp);
+            $edit_date = most_recent_date( $cp->edit_date, $edit_date );
+
+            # find the corresponding serial.
+            # There is no way of knowing here if the barcode 852$p is a correct match.
+            my $order = 0 ;
+            my $ser;
+            foreach my $key (sort keys %serials) {
+                my $serial = $serials{$key};
+                if ( $serial->{'label'} eq $cn_label ) {
+                    $ser = $serial->{'ser'};
+                    $order = $key;
+                    delete $serials{$key}; # in case we have several serial holdings with the same call number
+                    last;
+               }
+            }
+            $holdings{$cn_label}{'order'} = $order ;
+
+            my $circlib = $cp->circ_lib->shortname ;
+            push @{$holdings{$cn->label}{'copies'}}, {
+                owning_lib => $cn->owning_lib->shortname,
+                callnumber => $cn->label,
+                barcode    => $cp->barcode,
+                status     => $cp->status->name,
+                location   => $cp->location->name,
+                circlib    => $circlib,
+                ser        => $ser
+            };
+        }
+    }
+
+    ## Append the holdings and MFHD data to the marc record and apply the stylesheet.
+    if ( %holdings ) {
+
+        # Force record leader to 'a' as our data is always UTF8
+        # Avoids marc8_to_utf8 from being invoked with horrible results
+        # on the off-chance the record leader isn't correct
+        my $ldr = $marc->leader;
+        substr($ldr, 9, 1, 'a');
+        $marc->leader($ldr);
+
+        # Expects the record ID in the 001
+        $marc->delete_field($_) for ($marc->field('001'));
+        if (!$marc->field('001')) {
+            $marc->insert_fields_ordered(
+                MARC::Field->new( '001', $tcn )
+            );
+        }
+
+        # Our reference node to prepend nodes to.
+        my $reference = $marc->field('901');
+
+        $marc->delete_field($_) for ($marc->field('852')); # remove any legacy 852s
+        foreach my $cn (sort { $holdings{$a}->{'order'} <=> $holdings{$b}->{'order'}} keys %holdings) {
+            foreach my $cp (@{$holdings{$cn}->{'copies'}}) {
+                my $marc_852 = MARC::Field->new(
+                   '852', '4', ' ', 0 => 'dummy'); # The dummy is necessary to prevent a validation error.
+                foreach my $subfield_code (sort keys %copies) {
+                    my $_cp = $copies{$subfield_code} ;
+                    $marc_852->add_subfields($subfield_code, $cp->{$_cp} || $_cp) if ($_cp);
+                }
+                $marc_852->delete_subfield(code => '0');
+                $marc->insert_fields_before($reference, $marc_852);
+                if ( $cp->{'ser'} ) {
+                    my $marc_866_a = MARC::Field->new( '866', '4', ' ', 'a' => $cp->{'ser'});
+                    $marc->insert_fields_after( $marc_852, $marc_866_a ) ;
+                }
+            }
+        }
+
+    }
+
+    my $xslt = $record_xslt{$metadataPrefix}{xslt} ;
+    my $xml = $xslt->transform( $_parser->parse_string( $marc->as_xml_record()) );
+    return $xslt->output_as_chars( $xml ) ;
+}
+
+
+sub most_recent_date {
+
+    my $date1 = substr(shift, 0, 19) ;  # e.g. '2001-02-03T04:05:06+0000' becomes '2001-02-03T04:05:06'
+    my $date2 = substr(shift, 0, 19) ;
+    my $_date1 = $date1 ;
+    my $_date2 = $date2 ;
+
+    $date1 =~ s/[-T:\.\+]//g ; # '2001-02-03T04:05:06' becomes '20010203040506'
+    $date2 =~ s/[-T:\.\+]//g ;
+
+    return $_date1 if ( $date1 > $date2) ;
+    return $_date2 ;
+}
+
+
+sub _cp_is_visible {
+
+    my $cn = shift;
+    my $cp = shift;
+
+    my $visible = 0;
+    if ( ($cp->deleted eq 'f' || !$cp->deleted) &&
+         ( ! $barcode_filter || $cp->barcode =~ /$barcode_filter/ ) &&
+         $cp->location->opac_visible eq 't' &&
+         $cp->status->opac_visible eq 't' &&
+         $cp->opac_visible eq 't' &&
+         $cp->circ_lib->opac_visible eq 't' &&
+         $cn->owning_lib->opac_visible eq 't' &&
+         (! $status_filter || $cp->status->name =~ /$status_filter/ )
+    ) {
+        $visible = 1;
+    }
+
+    return $visible;
+}
+
+__PACKAGE__->register_method(
+    method    => 'oai_biblio_retrieve',
+    api_name  => 'open-ils.oai.biblio.retrieve',
+    api_level => 1,
+    argc      => 1,
+    signature =>
+    {
+        desc     => 'Returns the MARCXML representation of the requested bibliographic record.',
+        params   =>
+        [
+            {
+                name => 'tcn',
+                desc => 'An OpenILS biblio::record_entry id.',
+                type => 'number'
+            },
+            {
+                name => 'metadataPrefix',
+                desc => 'The metadataPrefix of the schema.',
+                type => 'string'
+            }
+        ],
+        'return' =>
+        {
+            desc => 'An string of the XML in the desired schema.',
+            type => 'string'
+        }
+    }
+);
+
+
+sub oai_authority_retrieve {
+
+    my $self = shift;
+    my $client = shift;
+    my $tcn = shift;
+    my $metadataPrefix = shift;
+
+    my $_storage = OpenSRF::AppSession->create( 'open-ils.cstore' );
+
+    # Retrieve the authority record
+    my $record = $_storage->request('open-ils.cstore.direct.authority.record_entry.retrieve', $tcn)->gather(1);
+    my $o = Fieldmapper::authority::record_entry->new($record) ;
+    my $marc = MARC::Record->new_from_xml( $o->marc, 'UTF8', 'XML');
+
+    # Expects the record ID in the 001
+    $marc->delete_field($_) for ($marc->field('001'));
+    if (!$marc->field('001')) {
+        $marc->insert_fields_ordered(
+            MARC::Field->new( '001', $tcn )
+        );
+    }
+
+    my $xslt = $record_xslt{$metadataPrefix}{xslt} ;
+    my $xml = $record_xslt{$metadataPrefix}{xslt}->transform(
+       $_parser->parse_string( $marc->as_xml_record())
+    );
+    return $record_xslt{$metadataPrefix}{xslt}->output_as_chars( $xml ) ;
+}
+
+
+__PACKAGE__->register_method(
+    method    => 'oai_authority_retrieve',
+    api_name  => 'open-ils.oai.authority.retrieve',
+    api_level => 1,
+    argc      => 1,
+    signature =>
+    {
+        desc     => 'Returns the MARCXML representation of the requested authority record.',
+        params   =>
+        [
+            {
+                name => 'tcn',
+                desc => 'An OpenILS authority::record_entry id.',
+                type => 'number'
+            },
+            {
+                name => 'metadataPrefix',
+                desc => 'The metadataPrefix of the schema.',
+                type => 'string'
+            }
+        ],
+        'return' =>
+        {
+            desc => 'An string of the XML in the desired schema.',
+            type => 'string'
+        }
+    }
+);
+
+
+sub oai_list_retrieve {
+
+    my $self            = shift;
+    my $client          = shift;
+    my $record_class    = shift || 'biblio';
+    my $tcn             = shift || 0;
+    my $from            = shift;
+    my $until           = shift;
+    my $set             = shift ;
+    my $metadataPrefix  = shift;
+    my $max_count       = shift;
+    my $deleted_record  = shift || 'yes';
+
+    my $query = {};
+    $query->{'tcn'}       = ($max_count eq 1) ? $tcn : {'>=' => $tcn} ;
+    $query->{'set_spec'}  = $set                     if ( $set ); # unsupported
+    $query->{'deleted'}   = 'f'                      unless ( $deleted_record eq 'yes' );
+    $query->{'datestamp'} = {'>=', $from}            if ( $from && !$until ) ;
+    $query->{'datestamp'} = {'<=', $until}           if ( !$from && $until ) ;
+    $query->{'-and'}      = [{'datestamp'=>{'>=' => $from}}, {'datestamp'=>{'<=' => $until}}] if ( $from && $until ) ;
+
+    my $_storage = OpenSRF::AppSession->create( 'open-ils.cstore' );
+    return $_storage->request('open-ils.cstore.direct.oai.' . $record_class . '.search.atomic',
+            $query,
+            {
+                limit => $max_count + 1
+            }
+        )->gather(1);
+}
+
+__PACKAGE__->register_method(
+    method    => 'oai_list_retrieve',
+    api_name  => 'open-ils.oai.list.retrieve',
+    api_level => 1,
+    argc      => 1,
+    signature =>
+    {
+        desc => 'Returns a list of record identifiers.',
+        params =>
+        [
+            {
+                name => 'record_class',
+                desc => '\'biblio\' for bibliographic records or \'authority\' for authority records',
+                type => 'string'
+            },            {
+                name => 'tcn',
+                desc => 'An optional tcn number used as a cursor.',
+                type => 'number'
+            },
+            {
+                name => 'from',
+                desc => 'The datestamp the resultset range should begin with.',
+                type => 'string'
+            },
+            {
+                name => 'until',
+                desc => 'The datestamp the resultset range should end with.',
+                type => 'string'
+            },
+            {
+                name => 'set',
+                desc => 'A setspec.',
+                type => 'string'
+            },
+            {
+                name => 'metadataPrefix',
+                desc => 'The metadataPrefix of the schema.',
+                type => 'string'
+            },
+            {
+                name => 'offset',
+                desc => 'The start of the cursor position in the result set.',
+                type => 'number'
+            },
+            {
+                name => 'deleted_record',
+                desc => 'If set to \'no\' the response will only include active records.',
+                type => 'string'
+            }
+        ],
+        'return' =>
+        {
+            desc => 'An OAI type record.',
+            type => 'array'
+        }
+    }
+);
+
+
+1;
\ No newline at end of file
diff --git a/Open-ILS/src/perlmods/lib/OpenILS/WWW/OAI.pm b/Open-ILS/src/perlmods/lib/OpenILS/WWW/OAI.pm
new file mode 100644 (file)
index 0000000..7fc5f36
--- /dev/null
@@ -0,0 +1,478 @@
+# OpenILS::WWW::OAI manages OAI2 requests and responses.
+#
+# Copyright (c) 2014-2017  International Institute of Social History
+#
+# This program 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 3 of the License, or
+# (at your option) any later version.
+#
+# This program 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 this program.  If not, see <http://www.gnu.org/licenses/>.
+#
+#
+# Author: Lucien van Wouw <lwo@iisg.nl>
+
+
+package OpenILS::WWW::OAI;
+use strict; use warnings;
+use Apache2::Const -compile => qw(OK REDIRECT DECLINED NOT_FOUND :log);
+use CGI;
+use DateTime::Format::ISO8601;
+use HTTP::OAI;
+use HTTP::OAI::Metadata::OAI_Identifier;
+use HTTP::OAI::Repository qw/:validate/;
+use MARC::File::XML ( BinaryEncoding => 'UTF-8' );
+use MARC::Record;
+use MIME::Base64;
+use OpenSRF::EX qw(:try);
+use OpenSRF::Utils::Logger qw/$logger/;
+use XML::LibXML;
+use XML::LibXSLT;
+
+my (
+    $bootstrap,
+    $base_url,
+    $repository_identifier,
+    $repository_name,
+    $admin_email,
+    $earliest_datestamp,
+    $deleted_record,
+    $max_count,
+    $granularity,
+    $scheme,
+    $delimiter,
+    $sample_identifier,
+    $list_sets,
+    $oai_metadataformats,
+    $oai_sets,
+    $oai,
+    $parser,
+    $xslt
+);
+
+
+sub import {
+
+    my $self = shift;
+    $bootstrap = shift;
+}
+
+
+sub child_init {
+
+    OpenSRF::System->bootstrap_client( config_file => $bootstrap );
+
+    my $idl = OpenSRF::Utils::SettingsClient->new->config_value('IDL');
+    Fieldmapper->import(IDL => $idl);
+
+    $oai = OpenSRF::AppSession->create('open-ils.oai');
+    $parser = new XML::LibXML;
+    $xslt = new XML::LibXSLT;
+
+    my $app_settings = OpenSRF::Utils::SettingsClient->new->config_value(apps => 'open-ils.oai')->{'app_settings'};
+    $base_url = $app_settings->{'base_url'} || 'localhost';
+    $base_url =~/(.*)\/$/ ; # Keep all minus the trailing forward slash.
+    $repository_identifier = $app_settings->{'repository_identifier'} || 'localhost';
+    $repository_name = $app_settings->{'repository_name '} || 'A name';
+    $admin_email = $app_settings->{'admin_email'} || 'adminEmail@' . $repository_identifier ;
+    $earliest_datestamp =  $app_settings->{'earliest_datestamp'} || '0001-01-01' ;
+    $deleted_record = $app_settings->{'deleted_record'} || 'yes' ;
+    $max_count = $app_settings->{'max_count'} || 50;
+    $granularity = $app_settings->{'granularity' } || 'YYYY-MM-DDThh:mm:ss';
+    $scheme = $app_settings->{'scheme'} || 'oai';
+    $delimiter = $app_settings->{'delimiter'} || ':';
+    $sample_identifier = $app_settings->{'sample_identifier'} || $scheme . $delimiter . $repository_identifier . $delimiter . '12345' ;
+    $list_sets = $app_settings->{'list_sets'} || 0;
+
+    if ( $list_sets ) {
+        _load_oaisets_authority();
+        _load_oaisets_biblio();
+    }
+    _load_oai_metadataformats();
+
+    return Apache2::Const::OK;
+}
+
+
+sub handler {
+
+    my $apache = shift;
+    return Apache2::Const::DECLINED if (-e $apache->filename);
+
+    unless (defined $oai) {
+        $logger->error('Application session variables not defined. Add \'PerlChildInitHandler OpenILS::WWW::OAI::child_init\' to the Apache virtual host configuration file.');
+        child_init();
+    }
+
+    my $cgi = new CGI;
+    my $record_class;
+    if ( $cgi->path_info =~ /\/(authority|biblio)/ ) {
+        $record_class = $1 ;
+    } else {
+        return Apache2::Const::NOT_FOUND ;
+    }
+
+    my %attr = $cgi->Vars();
+    my $requestURL = $base_url
+        . '/' . $record_class
+        . '?'
+        . join('&', map { "$_=$attr{$_}" } keys %attr);
+    $logger->info('Request url=' . $requestURL ) ;
+
+    my $response;
+    my @errors = validate_request( %attr );
+    if ( !@errors ) {
+
+        # Retrieve our parameters
+        my $verb = delete( $attr{verb} );
+        my $identifier = $attr{identifier};
+        my $metadataPrefix = $attr{metadataPrefix} ;
+        my $from = $attr{from};
+        my $until = $attr{'until'};
+        my $set = $attr{set};
+        my $resumptionToken = decode_base64($attr{resumptionToken} ) if $attr{resumptionToken};
+        my $offset = 0 ;
+        if ( $resumptionToken ) {
+            ($metadataPrefix, $from, $until, $set, $offset) = split( '\$', $resumptionToken );
+        }
+
+        # Is the set valid ?
+        if ( $set ) {
+            my $_set = $oai_sets->{$set};
+            if ( $_set && $_set->{id} && $_set->{record_class} eq $record_class) {
+                $set = $_set->{id} ;
+            } else {
+                push @errors, new HTTP::OAI::Error(code=>'noRecordsMatch', message=>"Set argument doesn't match any sets. The setSpec was '$set'") ;
+            }
+        }
+
+        # Are the from and until ranges aligned ?
+        if ( $from && $until ) {
+            my $_from = $from ;
+            my $_until = $until ;
+            $_from =~ s/[-T:\.\+Z]//g ; # '2001-02-03T04:05:06Z' becomes '20010203040506'
+            $_until =~ s/[-T:\.\+Z]//g ;
+            push @errors, new HTTP::OAI::Error(code=>'badArgument', message=>'Bad date values, must have from<=until') unless ($_from <= $_until);
+        }
+
+        # Is this metadataformat available ?
+        push @errors, new HTTP::OAI::Error(code=>'cannotDisseminateFormat', message=>'The metadata format identified by the value given for the metadataPrefix argument is not supported by the item or by the repository') unless ( ($verb eq 'ListMetadataFormats' || $verb eq 'ListSets' || $verb eq 'Identify') || $oai_metadataformats->{$metadataPrefix} );
+
+        if ( !@errors ) {
+
+            # Now prepare the response
+            if ( $verb eq 'ListRecords' ) {
+                $response = listRecords( $record_class, $requestURL, $from, $until, $set, $metadataPrefix, $offset);
+            }
+            elsif ( $verb eq 'ListMetadataFormats' ) {
+                $response = listMetadataFormats();
+            }
+            elsif ( $verb eq 'ListSets' ) {
+                $response = listSets( $record_class, $requestURL );
+            }
+            elsif ( $verb eq 'GetRecord' ) {
+                $response = getRecord( $record_class, $requestURL, $identifier, $metadataPrefix);
+            }
+            elsif ( $verb eq 'ListIdentifiers' ) {
+                $response = listIdentifiers( $record_class, $requestURL, $from, $until, $set, $metadataPrefix, $offset);
+            }
+            else { # Identify
+                $response = identify($record_class);
+            }
+        }
+    }
+
+    if ( @errors ) {
+        $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+        $response->errors(@errors);
+    }
+
+    $cgi->header(-type=>'text/xml', -charset=>'utf-8');
+    $cgi->print($response->toDOM->toString());
+
+    return Apache2::Const::OK;
+}
+
+
+sub identify {
+
+    my $record_class = shift;
+
+    my $response = HTTP::OAI::Identify->new(
+        protocolVersion     => '2.0',
+        baseURL             => $base_url . '/' . $record_class,
+        repositoryName      => $repository_name,
+        adminEmail          => $admin_email,
+        MaxCount            => $max_count,
+        granularity         => $granularity,
+        earliestDatestamp   => $earliest_datestamp,
+        deletedRecord       => $deleted_record
+    );
+
+    $response->description(
+        HTTP::OAI::Metadata::OAI_Identifier->new(
+            'scheme', $scheme,
+            'repositoryIdentifier' , $repository_identifier,
+            'delimiter', $delimiter,
+            'sampleIdentifier', $sample_identifier
+        )
+    );
+
+    return $response;
+}
+
+
+sub listMetadataFormats {
+
+    my $response = HTTP::OAI::ListMetadataFormats->new();
+    foreach my $metadataPrefix (keys $oai_metadataformats) {
+        my $metadata_format = $oai_metadataformats->{$metadataPrefix} ;
+        $response->metadataFormat( HTTP::OAI::MetadataFormat->new(
+           metadataPrefix    => $metadataPrefix,
+           schema            => $metadata_format->{schema},
+           metadataNamespace => $metadata_format->{metadataNamespace}
+        ) );
+    }
+
+    return $response;
+}
+
+
+sub listSets {
+
+    my ($record_class, $requestURL ) = @_;
+
+    if ($oai_sets) {
+        my $response = HTTP::OAI::ListSets->new( );
+        foreach my $key (keys $oai_sets) {
+            my $set = $oai_sets->{$key} ;
+            if ( $set && $set->{setSpec} && $set->{record_class} eq $record_class ) {
+                $response->set(
+                    HTTP::OAI::Set->new(
+                        setSpec => $set->{setSpec},
+                        setName => $set->{setName}
+                    )
+                );
+            }
+        }
+        return $response;
+    } else {
+        my @errors = (new HTTP::OAI::Error(code=>'noSetHierarchy', message=>'The repository does not support sets.') ) ;
+        my $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+        $response->errors(@errors);
+        return $response;
+    }
+}
+
+
+sub getRecord {
+
+    my ($record_class, $requestURL, $identifier, $metadataPrefix ) = @_;
+
+    my $response ;
+    my @errors;
+
+    # Do we have a valid identifier ?
+    my $regex_identifier = "^${scheme}${delimiter}${repository_identifier}${delimiter}([0-9]+)\$";
+    if ( $identifier =~ /$regex_identifier/i ) {
+        my $tcn = $1 ;
+
+        # Do we have a record ?
+        my $record = $oai->request('open-ils.oai.list.retrieve', $record_class, $tcn, undef, undef, undef, $metadataPrefix, 1, $deleted_record)->gather(1) ;
+        if (@$record) {
+            $response = HTTP::OAI::GetRecord->new();
+            my $o = "Fieldmapper::oai::$record_class"->new(@$record[0]);
+            $response->record(_record($record_class, $o, $metadataPrefix));
+        } else {
+            push @errors, new HTTP::OAI::Error(code=>'idDoesNotExist', message=>'The value of the identifier argument is unknown or illegal in this repository.') ;
+        }
+    }
+    else {
+         push @errors, new HTTP::OAI::Error(code=>'idDoesNotExist', message=>'The value of the identifier argument is unknown or illegal in this repository.') ;
+    }
+
+    if (@errors) {
+        $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+        $response->errors(@errors);
+    }
+
+    return $response;
+}
+
+
+sub listIdentifiers {
+
+    my ($record_class, $requestURL, $from, $until, $set, $metadataPrefix, $offset ) = @_;
+    my $response;
+
+    my $r = $oai->request('open-ils.oai.list.retrieve', $record_class, $offset, $from, $until, $set, $metadataPrefix, $max_count, $deleted_record)->gather(1) ;
+    if (@$r) {
+        my $cursor = 0 ;
+        $response = HTTP::OAI::ListIdentifiers->new();
+        for my $record (@$r) {
+            my $o = "Fieldmapper::oai::$record_class"->new($record) ;
+            if ( $cursor++ == $max_count ) {
+                my $token = new HTTP::OAI::ResumptionToken( resumptionToken => encode_base64(join( '$', $metadataPrefix, $from, $until, $oai_sets->{$set}->{setSpec}, $o->tcn ), '' ) ) ;
+                $token->cursor($offset);
+                $response->resumptionToken($token) ;
+            } else {
+                $response->identifier( _header($record_class, $o)) ;
+            }
+        }
+    } else {
+        my @errors = (new HTTP::OAI::Error(code=>'noRecordsMatch', message=>'The combination of the values of the from, until, set, and metadataPrefix arguments results in an empty list.') ) ;
+        $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+        $response->errors(@errors);
+    }
+
+    return $response ;
+}
+
+
+sub listRecords {
+
+    my ($record_class, $requestURL, $from, $until, $set, $metadataPrefix, $offset ) = @_;
+    my $response;
+
+    my $r = $oai->request('open-ils.oai.list.retrieve', $record_class, $offset, $from, $until, $set, $metadataPrefix, $max_count, $deleted_record)->gather(1) ;
+    if (@$r) {
+        my $cursor = 0 ;
+        $response = HTTP::OAI::ListRecords->new();
+        for my $record (@$r) {
+            my $o = "Fieldmapper::oai::$record_class"->new($record) ;
+            if ( $cursor++ == $max_count ) {
+                my $token = new HTTP::OAI::ResumptionToken( resumptionToken => encode_base64(join( '$', $metadataPrefix, $from, $until, $oai_sets->{$set}->{setSpec}, $o->tcn ), '' ) ) ;
+                $token->cursor($offset);
+                $response->resumptionToken($token) ;
+            } else {
+                $response->record(_record($record_class, $o, $metadataPrefix));
+            }
+        }
+    } else {
+        my @errors = (new HTTP::OAI::Error(code=>'noRecordsMatch', message=>'The combination of the values of the from, until, set, and metadataPrefix arguments results in an empty list.') ) ;
+        $response = HTTP::OAI::Response->new( requestURL => $requestURL );
+        $response->errors(@errors);
+    }
+
+    return $response ;
+}
+
+
+sub _header {
+
+    my ($record_class, $o) = @_;
+    my @set_spec;
+
+    my $status = 'deleted' if ($o->deleted eq 't');
+    my $s = $o->set_spec; # Here we get an array that was parsed as a string like "{1,2,3,4}"
+    $s =~ s/[{}]//g ;     # We remove the {}
+    foreach (split(',', $s)) { # and turn this into an array.
+        my $_set = $oai_sets->{$_};
+        push @set_spec, $_set->{setSpec} if ( $_set && $_set->{record_class} eq $record_class) ;
+    }
+
+    return new HTTP::OAI::Header(
+            identifier  => $scheme . $delimiter . $repository_identifier . $delimiter . $o->tcn,
+            datestamp   => substr($o->datestamp, 0, 19) . 'Z',
+            status      => $status,
+            setSpec     => \@set_spec
+        )
+}
+
+
+sub _record {
+
+    my ($record_class, $o, $metadataPrefix ) = @_;
+
+    my $record = HTTP::OAI::Record->new();
+    $record->header( _header($record_class, $o) );
+
+    if ( $o->deleted eq 'f' ) {
+        my $md = new HTTP::OAI::Metadata() ;
+        my $xml = $oai->request('open-ils.oai.' . $record_class . '.retrieve', $o->tcn, $metadataPrefix)->gather(1) ;
+        $md->dom( $parser->parse_string('<metadata>' . $xml . '</metadata>') ); # Not sure why I need to add the metadata element,
+        $record->metadata( $md );                                               # because I expect ->metadata() would provide the wrapper for it.
+    }
+
+    return $record ;
+}
+
+
+# _load_oaisets_authority
+# Populate the $oai_sets hash with the sets for authority records.
+# oai_sets = {id\setSpec => {id, setSpec, setName, record_class = 'authority' }}
+sub _load_oaisets_authority {
+
+    my $ses = OpenSRF::AppSession->create('open-ils.cstore');
+    my $r = $ses->request('open-ils.cstore.direct.authority.browse_axis.search.atomic',
+        {code => {'!=' => undef } } )->gather(1);
+
+    for my $record (@$r) {
+        my $o = Fieldmapper::authority::browse_axis->new($record) ;
+        $oai_sets->{$o->code} = {
+           id => $o->code,
+           setSpec => $o->code,
+           setName => $o->description, # description is more verbose than $o->name
+           record_class => 'authority'
+        };
+    }
+}
+
+
+# _load_oaisets_biblio
+# Populate the $oai_sets hash with the sets for bibliographic records. Those are org_type records
+# oai_sets = {id\setSpec => {id, setSpec, setName, record_class = 'biblio' }}
+sub _load_oaisets_biblio {
+
+    my $node = shift;
+    my $types = shift;
+    my $parent = shift;
+
+    unless ( $node ) {
+        my $ses = OpenSRF::AppSession->create('open-ils.actor');
+        $node = $ses->request('open-ils.actor.org_tree.retrieve')->gather(1);
+        my $aout = $ses->request('open-ils.actor.org_types.retrieve')->gather(1);
+        $ses->disconnect;
+        return unless ($node) ;
+
+        my @_types;
+        foreach my $type (@$aout) {
+            $_types[int($type->id)] = $type;
+        }
+        $types = \@_types;
+    }
+
+    return unless ($node->opac_visible =~ /^[y1t]+/i);
+
+    my $spec = ($parent) ? $parent . ':' . $node->shortname : $node->shortname ;
+    $oai_sets->{$spec} = {id => $node->id, record_class => 'biblio' };
+    $oai_sets->{$node->id} = {setSpec => $spec, setName => $node->name, record_class => 'biblio' };
+
+    my $kids = $node->children;
+    _load_oaisets_biblio($_, $types, $spec) for (@$kids);
+}
+
+
+# _load_oai_metadataformats
+# Populate the $oai_metadataformats hash with the supported metadata formats:
+# oai_metadataformats = { metadataPrefix => { schema, metadataNamespace } }
+sub _load_oai_metadataformats {
+
+    my $list = $oai->request('open-ils.oai.record.formats')->gather(1);
+    for my $record_browse_format ( @$list ) {
+        my %h = %$record_browse_format ;
+        my $metadataPrefix = (keys %h)[0] ;
+        $oai_metadataformats->{$metadataPrefix} = {
+           schema            => $h{$metadataPrefix}->{'namespace_uri'},
+           metadataNamespace => $h{$metadataPrefix}->{'schema_location'}
+        };
+    }
+}
+
+1;
diff --git a/Open-ILS/src/sql/Pg/oai.sql b/Open-ILS/src/sql/Pg/oai.sql
new file mode 100644 (file)
index 0000000..64688b9
--- /dev/null
@@ -0,0 +1,68 @@
+-- VIEWS for the oai service
+CREATE SCHEMA oai;
+
+-- The view presents a lean table with unique bre.tc-numbers for oai paging;
+CREATE VIEW oai.biblio AS
+  SELECT
+    bre.id                             AS tcn,
+    bre.edit_date                      AS datestamp,
+    bre.deleted                        AS deleted
+  FROM
+    biblio.record_entry bre
+  ORDER BY
+    bre.id;
+
+-- The view presents a lean table with unique are.tc-numbers for oai paging;
+CREATE VIEW oai.authority AS
+  SELECT
+    are.id               AS tcn,
+    are.edit_date        AS datestamp,
+    are.deleted          AS deleted
+  FROM
+    authority.record_entry AS are
+  ORDER BY
+    are.id;
+
+-- If an edit date changes in the asset.call_number or asset.copy and you want this to persist to an OAI2 datestamp,
+-- then add these stored procedures and triggers:
+CREATE OR REPLACE FUNCTION oai.datestamp(rid BIGINT)
+  RETURNS VOID AS $$
+BEGIN
+  UPDATE biblio.record_entry AS bre
+  SET edit_date = now()
+  WHERE bre.id = rid;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION oai.call_number_datestamp()
+  RETURNS TRIGGER AS $$
+BEGIN
+  IF TG_OP = 'DELETE'
+  THEN
+    PERFORM oai.datestamp(OLD.record);
+    RETURN OLD;
+  END IF;
+
+  PERFORM oai.datestamp(NEW.record);
+  RETURN NEW;
+
+END
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION oai.copy_datestamp()
+  RETURNS TRIGGER AS $$
+BEGIN
+  IF TG_OP = 'DELETE'
+  THEN
+    PERFORM oai.datestamp((SELECT acn.record FROM asset.call_number as acn WHERE acn.id = OLD.call_number));
+    RETURN OLD;
+  END IF;
+
+  PERFORM oai.datestamp((SELECT acn.record FROM asset.call_number as acn WHERE acn.id = NEW.call_number));
+  RETURN NEW;
+
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER call_number_datestamp AFTER INSERT OR UPDATE OR DELETE ON asset.call_number FOR EACH ROW EXECUTE PROCEDURE oai.call_number_datestamp();
+CREATE TRIGGER copy_datestamp AFTER INSERT OR UPDATE OR DELETE ON asset.copy FOR EACH ROW EXECUTE PROCEDURE oai.copy_datestamp();
\ No newline at end of file
diff --git a/Open-ILS/xsl/OAI2_MARC21slim.xsl b/Open-ILS/xsl/OAI2_MARC21slim.xsl
new file mode 100644 (file)
index 0000000..3bf4226
--- /dev/null
@@ -0,0 +1,18 @@
+<xsl:stylesheet version="1.0"
+                xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
+                xmlns:marc="http://www.loc.gov/MARC21/slim">
+    <xsl:output omit-xml-declaration="yes"/>
+
+    <xsl:template match="node()|@*">
+        <xsl:copy>
+            <xsl:apply-templates select="node()|@*"/>
+        </xsl:copy>
+    </xsl:template>
+
+    <xsl:template match="*">
+        <xsl:element name="marc:{name()}" namespace="http://www.loc.gov/MARC21/slim">
+            <xsl:copy-of select="namespace::*"/>
+            <xsl:apply-templates select="node()|@*"/>
+        </xsl:element>
+    </xsl:template>
+</xsl:stylesheet>
\ No newline at end of file
diff --git a/Open-ILS/xsl/OAI2_OAIDC.xsl b/Open-ILS/xsl/OAI2_OAIDC.xsl
new file mode 100644 (file)
index 0000000..4434360
--- /dev/null
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<xsl:stylesheet version="1.0" xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
+       <xsl:import href="MARC21slim2OAIDC.xsl"/>
+       <xsl:output omit-xml-declaration="yes"/>
+
+    <xsl:template match="/">
+                       <oai_dc:dc xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/oai_dc/ http://www.openarchives.org/OAI/2.0/oai_dc.xsd"
+                       xmlns:dc="http://purl.org/dc/elements/1.1/">
+                               <xsl:apply-templates/>
+                       </oai_dc:dc>
+       </xsl:template>
+
+</xsl:stylesheet>
\ No newline at end of file
diff --git a/docs/RELEASE_NOTES_NEXT/OAI2/install.adoc b/docs/RELEASE_NOTES_NEXT/OAI2/install.adoc
new file mode 100644 (file)
index 0000000..e54eb3a
--- /dev/null
@@ -0,0 +1,408 @@
+= oai-openils is an openSRF service
+
+This module is an optional service that exposes your catalog through the [OAI2 protocol](http://www.openarchives.org/OAI/openarchivesprotocol.html).
+
+== 1. Intended behaviour
+
+=== 1.1 Entry points
+There are two: one for bibliographic records and one for authority records:
+
+    http://your-domain/opac/extras/oai/authority
+    http://your-domain/opac/extras/oai/biblio
+=== 1.2 Setspec are not implemented
+
+This is a work in progress and not enabled. The aim is to have the owning library determine the set hierarchy. The Concerto
+test database for example has a record with tcn #1. This record is so popular it has copies attached to library units
+"Example Branch 1", "Example Branch 2", "Example Branch 3", "Example Bookmobile 1" which is a child of Branch 3 and
+"Example Branch 4". This entire kinship is expressed as sets like so: 
+
+```xml
+<header>
+    ...
+    <setSpec>CONS</setSpec>
+    <setSpec>CONS:SYS1</setSpec>
+    <setSpec>CONS:SYS2</setSpec>
+    <setSpec>CONS:SYS1:BR1</setSpec>
+    <setSpec>CONS:SYS1:BR2</setSpec>
+    <setSpec>CONS:SYS2:BR3</setSpec>
+    <setSpec>CONS:SYS2:BR4</setSpec>
+    <setSpec>CONS:SYS2:BR3:BM1</setSpec>
+</header>
+```
+Likewise the setSpecs of authority records are derived from their browse axis ( Title, Author, Subject and Topic ).
+
+=== 1.3 OAI2 datestamp
+
+The edit date of the bibliographic and authority record is used as datestamp. If you want the date for editorial updates
+of bibliographic assets ( copies, call numbers ) reflected in the datestamp, then add the triggers shown below.
+
+=== 1.4 Bibliographic mapping of assets to 852 subfields
+
+Certain attributes asset are placed into 852 subfields so:
+
+| subfield code | asset resource |
+| --- | --- |
+| a | location |
+| b | owning_lib |
+| c | callnumber |
+| d | circlib |
+| g | barcode |
+| n | status |
+Thus the Concerto with tcn #1 will have it's 852 subfields expressed as:
+```xml
+<marc:datafield ind1="4" ind2=" " tag="852">
+    <marc:subfield code="a">Stacks</marc:subfield>
+    <marc:subfield code="b">BR4</marc:subfield>
+    <marc:subfield code="c">ML 60 R100</marc:subfield>
+    <marc:subfield code="d">BR4</marc:subfield>
+    <marc:subfield code="g">CONC70000435</marc:subfield>
+    <marc:subfield code="n">Checked out</marc:subfield>
+</marc:datafield>
+```
+This mapping can be customized and extended with static subfields:
+```xml
+    <marc:subfield code="q">A constant value</marc:subfield>
+```
+
+=== 1.5 Default configuration
+
+All default configuration is commented in the open-ils.oai app_settings element. See below for details on how to
+override defaults by removing the comments and substitute the values.
+
+== 2. Installation
+
+=== 2.1 Perl modules
+
+Lookup the Perl handler and the associated openils module:
+
+ - [Open-ILS/src/perlmods/lib/OpenILS/WWW/OAI.pm](Open-ILS/src/perlmods/lib/OpenILS/WWW/OAI.pm)
+ - [Open-ILS/src/perlmods/lib/OpenILS/Application/OAI.pm](Open-ILS/src/perlmods/lib/OpenILS/Application/OAI.pm)
+
+Place them in your codebase next to the other openils modules and let them thus become part of the build:
+
+    Open-ILS/src/perlmods/lib/OpenILS/Application/OAI.pm
+    Open-ILS/src/perlmods/lib/OpenILS/WWW/OAI.pm
+
+or copy the files (owned by the opensrf user) on your servers that host the openils services in the Perl library path:
+
+    /the perl library path/OpenILS/Application/OAI.pm
+    /the perl library path/OpenILS/WWW/OAI.pm
+
+=== 2.2 Declare the perl handler
+
+Declare the Perl handler in the Apache eg_startup file:
+
+```perl
+use OpenILS::WWW::OAI qw( <openils sysdir>conf/opensrf_core.xml );
+```
+    
+And reference it in the Apache eg_vhost.conf file, apache 2.2:
+
+    <Location /opac/extras/oai>
+        SetHandler perl-script
+        PerlHandler OpenILS::WWW::OAI
+        Options +ExecCGI
+        PerlSendHeader On
+        allow from all
+    </Location>
+
+or apache 2.4
+
+    <Location /opac/extras/oai>
+        SetHandler perl-script
+        PerlHandler OpenILS::WWW::OAI
+        Options +ExecCGI
+        PerlSendHeader On
+        Require all granted
+    </Location>
+
+In the eg.conf file under 'PerlRequire /etc/apache2/eg_startup' add:
+```apache
+PerlChildInitHandler OpenILS::WWW::OAI::child_init
+
+```
+
+=== 2.3 The database and fieldmapper
+
+==== 2.3.1 Database
+
+The service requires a view and stored procedures: Open-ILS/src/sql/Pg/oai.sql
+
+Add the oai section to the database:
+```sql
+-- VIEWS for the oai service
+CREATE SCHEMA oai;
+
+
+-- The view presents a lean table with unique bre.tc-numbers for oai paging;
+CREATE VIEW oai.biblio AS
+  SELECT
+    bre.id                             AS tcn,
+    bre.edit_date                      AS datestamp,
+    bre.deleted                        AS deleted
+  FROM
+    biblio.record_entry bre
+  ORDER BY
+    bre.id;
+
+-- The view presents a lean table with unique are.tc-numbers for oai paging;
+CREATE VIEW oai.authority AS
+  SELECT
+    are.id               AS tcn,
+    are.edit_date        AS datestamp,
+    are.deleted          AS deleted
+  FROM
+    authority.record_entry AS are
+  ORDER BY
+    are.id;
+```
+
+==== 2.3.2 Optional, setting the datestamp
+
+If you want the OAI2 datestamp to reflect changes in assets as well, add the following triggers
+ ```sql
+-- If an edit date changes in the asset.call_number or asset.copy and you want this to persist to an OAI2 datestamp,
+-- then add these stored procedures and triggers:
+CREATE OR REPLACE FUNCTION oai.datestamp(rid BIGINT)
+  RETURNS VOID AS $$
+BEGIN
+  UPDATE biblio.record_entry AS bre
+  SET edit_date = now()
+  WHERE bre.id = rid;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION oai.call_number_datestamp()
+  RETURNS TRIGGER AS $$
+BEGIN
+  IF TG_OP = 'DELETE'
+  THEN
+    PERFORM oai.datestamp(OLD.record);
+    RETURN OLD;
+  END IF;
+
+  PERFORM oai.datestamp(NEW.record);
+  RETURN NEW;
+
+END
+$$ LANGUAGE plpgsql;
+
+CREATE OR REPLACE FUNCTION oai.copy_datestamp()
+  RETURNS TRIGGER AS $$
+BEGIN
+  IF TG_OP = 'DELETE'
+  THEN
+    PERFORM oai.datestamp((SELECT acn.record FROM asset.call_number as acn WHERE acn.id = OLD.call_number));
+    RETURN OLD;
+  END IF;
+
+  PERFORM oai.datestamp((SELECT acn.record FROM asset.call_number as acn WHERE acn.id = NEW.call_number));
+  RETURN NEW;
+
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER call_number_datestamp AFTER INSERT OR UPDATE OR DELETE ON asset.call_number FOR EACH ROW EXECUTE PROCEDURE oai.call_number_datestamp();
+CREATE TRIGGER copy_datestamp AFTER INSERT OR UPDATE OR DELETE ON asset.copy FOR EACH ROW EXECUTE PROCEDURE oai.copy_datestamp(); 
+ ```
+
+==== 2.3.3 The fieldmapper
+
+Proceed by declaring the views in the fm_IDL.xml file so, as the example shows here [Open-ILS/examples/fm_ILD.xml](Open-ILS/examples/fm_IDL.xml):
+
+```xml
+<class id="oai_biblio" controller="open-ils.cstore" oils_obj:fieldmapper="oai::biblio"
+       oils_persist:readonly="true" reporter:core="false" reporter:label="OAI2 record list"
+       oils_persist:tablename="oai.biblio">
+    <fields>
+        <field reporter:label="TCN Value\OAI identifier postfix" name="tcn" reporter:datatype="number"/>
+        <field reporter:label="Last edit date\OAI datestamp" name="datestamp" reporter:datatype="timestamp"/>
+        <field reporter:label="Is Deleted?" name="deleted" reporter:datatype="bool"/>
+        <field reporter:label="Setspec" name="set_spec" oils_persist:virtual="true"/>
+    </fields>
+</class>
+<class id="oai_authority" controller="open-ils.cstore" oils_obj:fieldmapper="oai::authority"
+       oils_persist:readonly="true" reporter:core="false" reporter:label="OAI2 record list"
+       oils_persist:tablename="oai.authority">
+    <fields>
+        <field reporter:label="TCN Value\OAI identifier postfix" name="tcn" reporter:datatype="number"/>
+        <field reporter:label="Last edit date\OAI datestamp" name="datestamp" reporter:datatype="timestamp"/>
+        <field reporter:label="Is Deleted?" name="deleted" reporter:datatype="bool"/>
+        <field reporter:label="Setspec" name="set_spec" oils_persist:virtual="true"/>
+    </fields>
+</class>
+```
+
+=== 2.4 The xslt stylesheets
+
+Lookup the two documents here:
+
+ - [Open-ILS/xsl/OAI2_OAIDC.xsl](Open-ILS/xsl/OAI2_OAIDC.xsl)
+ - [Open-ILS/xsl/OAI2_MARC21slim.xsl](Open-ILS/xsl/OAI2_MARC21slim.xsl)
+
+Place the stylesheets in your codebase next to the other xsl documents and let them thus become part of the build.
+Or install them on your servers that host the openils services:
+
+    /<openils sysdir>/var/xsl/OAI2_OAIDC.xsl
+    /<openils sysdir>/var/xsl/OAI2_MARC21slim.xsl
+    
+=== 2.5 Dependencies
+The openils-oai service depends on a running openils-supercat service.
+And the OAI2_OAIDC.xsl document uses the file [MARC21slim2OAIDC.xsl](Open-ILS/xsl/MARC21slim2OAIDC.xsl).
+The service and stylesheet are part of the out-of-the-box Evergreen distributions.
+        
+But do install the ['HTTP::OAI' perl library from a CPAN repository](http://search.cpan.org/dist/HTTP-OAI/):
+
+    $ cpan HTTP::OAI    
+    
+
+== 3. Configuration
+
+=== 3.1 Declare the service
+
+Add the openils-oai service to your /&lt;openils sysdir&gt;/conf/opensrf.xml file.
+```xml
+....
+<open-ils.oai>
+    <keepalive>5</keepalive>
+    <stateless>1</stateless>
+    <language>perl</language>
+    <implementation>OpenILS::Application::OAI</implementation>
+    <max_requests>199</max_requests>
+    <unix_config>
+        <unix_sock>open-ils.oai_unix.sock</unix_sock>
+        <unix_pid>open-ils.oai_unix.pid</unix_pid>
+        <max_requests>1000</max_requests>
+        <unix_log>open-ils.oai_unix.log</unix_log>
+        <min_children>1</min_children>
+        <max_children>5</max_children>
+        <min_spare_children>1</min_spare_children>
+        <max_spare_children>2</max_spare_children>
+    </unix_config>
+    <app_settings>
+
+        <!-- Where necessary, override the default settings here in the app_settings element. -->
+
+        <!-- The OAI endpoint. The domain is the name of your proxy or frontend opac website. -->
+        <!-- <base_url>http://mydomain.org/opac/extras/oai</base_url> -->
+
+        <!-- <repository_name>My organization(s)</repository_name> -->
+        <!-- <admin_email>admin@mydomain.org</admin_email> -->
+
+        <!-- The maximum number of records in a ListRecords and ListIdentifiers response. -->
+        <!-- <max_count>50</max_count> -->
+
+        <!-- <granularity>YYYY-MM-DDThh:mm:ss</granularity> -->
+        <!-- <earliest_datestamp>0001-01-01</earliest_datestamp> -->
+        <!-- <deleted_record>yes</deleted_record> -->
+        <!-- <scheme>oai</scheme> -->
+        <!-- <repository_identifier>mydomain.org</repository_identifier> -->
+        <!-- <delimiter>:</delimiter> -->
+        <!-- <sample_identifier>oai:mydomain.org:12345</sample_identifier> -->
+        <!-- <list_sets>false</list_sets> -->
+
+        <!--
+        The metadataformat element contains the schema for the oai_dc and marcxml metadata formats.
+        Each schema needs a reference to an xslt document.
+        You can replace them with your custom xslt stylesheets.
+        Place those in the /<openils sysdir>/var/xsl folder.
+        You can also extend the OAI2 service further with new metadata schema.
+        
+        Bibliographic and authority records share the same stylesheet.
+        Should you want to render them differently, use the
+        marc:datafield[@tag='901']/marc:subfield[@code='t']
+        value to identify the record type. -->
+
+        <!--
+        <metadataformat>
+            <oai_dc>
+                <namespace_uri>http://www.openarchives.org/OAI/2.0/oai_dc/</namespace_uri>
+                <schema_location>http://www.openarchives.org/OAI/2.0/oai_dc.xsd</schema_location>
+                <xslt>OAI2_OAIDC.xsl</xslt>
+            </oai_dc>
+            <marcxml>
+                <namespace_uri>http://www.loc.gov/MARC21/slim</namespace_uri>
+                <schema_location>http://www.loc.gov/standards/marcxml/schema/MARC21slim.xsd</schema_location>
+                <xslt>OAI2_MARC21slim.xsl</xslt>
+            </marcxml>
+        </metadataformat> -->
+
+        <!--
+        You can add different schema to the metadataformat element thus:
+            <mods>
+                <namespace_uri>http://www.loc.gov/mods/</namespace_uri>
+                <schema_location>http://www.loc.gov/standards/mods/mods.xsd</schema_location>
+                <xslt>my-custom-marc2mods.xsl</xslt>
+            </mods>
+            <my-metadata_prefix>
+                <namespace_uri>my-namespace_uri</namespace_uri>
+                <schema_location>my-schema_location</schema_location>
+                <xslt>my-marc2my-metadata.xsl</xslt>
+            </my-metadata_prefix>
+        -->
+
+        <!-- Change the way the asset copy values are mapped to which subfield codes: -->
+        <!--
+        <copies>
+            <a>location</a>
+            <b>owning_lib</b>
+            <c>callnumber</c>
+            <d>circlib</d>
+            <g>barcode</g>
+            <n>status</n>
+        </copies>
+        -->
+        <!-- Or add static values to the copies element like this:
+            <z>A value that always should for example be in the 852$z</z>
+        -->
+        
+        <!-- Accept only 852$[barcode] values that match this regular expression. E.g.
+        <barcode_filter>^[A-Za-z0-9]+</barcode_filter>
+        only renders 852 datafields that contain barcodes values that begin with letters and numbers. 
+        <!--
+        <barcode_filter><barcode_filter>
+        -->
+                
+        <!-- Accept only 852$[status] values that match this regular expression. E.g.
+        <status_filter>^Available$</status_filter>
+        only renders 852 datafields that contain status code values that exactly match the string 'Available'. 
+        <!--
+        <status_filter></status_filter>
+        -->
+
+    </app_settings>
+</open-ils.oai>
+```
+
+==== 3.2 Activate the service
+
+Refer to the service in the opensrf.xml's activeapps element:
+```xml
+....
+<activeapps>
+    <appname>open-ils.oai</appname>
+```
+
+==== 3.3 Register the service with the router
+
+Add the service to the public router with your /&lt;openils sysdir&gt;/conf/opensrf_core.xml
+```xml
+<config>
+    <opensrf>
+        <routers>
+            <router>
+                <name>router</name>
+                <domain>public.realm</domain>
+                <services>
+                    <service>openils.oai</service>
+                    ...
+```
+    
+
+
+
+
+