Skip to content

scriptotek/php-alma-client

Repository files navigation

Build Status Scrutinizer code quality StyleCI Packagist

php-alma-client

Simple PHP package for working with the Alma REST APIs. SOAP APIs will not be supported. Currently, this package supports Bibs (read/write), Users and Analytics (read only). It is integrated with php-marc for editing MARC records. If the package doesn't fit your needs, you might take a look at the alternative php-alma package.

Table of Contents

Created by gh-md-toc

Install using Composer

Use Composer to install sru-client with a HTTP library such as Guzzle:

composer require scriptotek/alma-client php-http/guzzle6-adapter http-interop/http-factory-guzzle

We use HTTP discovery to discover HTTP client and HTTP factory implementations, so Guzzle can be swapped with any other PSR-17/PSR-18-compatible library.

Initializing a client

Start by initiating a new client with the API key you get at Ex Libris Developer Network:

require_once('vendor/autoload.php');
use Scriptotek\Alma\Client as AlmaClient;
use Scriptotek\Sru\Client as SruClient;

$alma = new AlmaClient('MY_SECRET_API_KEY', 'eu');

where 'eu' is the region code for Europe (use 'na' for North America or 'ap' for Asia Pacific). By default, the API entry point URL is generated from the region code, but if you're connecting to the API through a proxy, you can configure a different URL:

$alma = new AlmaClient('MY_SECRET_API_KEY')
    ->setEntryPoint('https://gw-uio.intark.uh-it.no/alma/v1');

If your Alma instance is connected to a network zone and you want to work with bib records there, you can also add an API key for the network zone:

$alma->nz->setKey('MY_SECRET_NETWORK_ZONE_API_KEY');

If you want search support, connect an SRU client:

$alma->setSruClient(new SruClient(
    'https://bibsys-k.alma.exlibrisgroup.com/view/sru/47BIBSYS_UBO',
    ['version' => '1.2']
));

You can also connect an SRU client to the network zone SRU service:

$alma->nz->setSruClient(new SruClient(
    'https://bibsys-k.alma.exlibrisgroup.com/view/sru/47BIBSYS_NETWORK',
    ['version' => '1.2']
));

Quick intro

The library provides access to Bibs, Holdings and Items through $alma->bibs, Users ($alma->users), Analytics reports ($alma->analytics), Libraries ($alma->libraries), Task Lists ($alma->taskLists) and Jobs ($alma->jobs).

To fetch a Bib record:

$bib = $alma->bibs->get('990114012304702204');

Note about lazy-loading and existence-checking

Lazy loading: Note that the client uses lazy loading to reduce the number of HTTP requests. Requests are not made when you instantiate objects, but when you request data from them. So in this case, a HTTP request for the holding record is first made when you call getRecord(). The response is then cached on the object for re-use. In the same way, no HTTP request is made for the Bib record in this case, since we don't request any data from that.

In general the library delays fetching data until it's actually needed, a practice called lazy-loading. This means you can for instance initialize a Bib object and echo back the MMS id without any network requests taking place, since there is no need to fetch any data yet:

$bib = $alma->bibs->get('9901140123047044111');
echo $bib->mms_id;

If you request anything else on the Bib object, like the title, the data will automatically be fetched and populated on the object:

echo $bib->title;

If the resource did not exist, a ResourceNotFound exception would be thrown at this point. So you could go ahead and handle that case like so:

$bib = $alma->bibs->get('9901140123047044111');
try {
    echo $bib->title;
} catch (\Scriptotek\Alma\Exception\ResourceNotFound $exc) {
    // Handle the case when the record doesn't exist
}

But you can also use the exists() method, which is often more convenient:

$bib = $alma->bibs->get('9901140123047044111');
if (!$bib->exists()) {
    // Handle the case when the record doesn't exist
}

Bibs: Bibliographic records, Holdings, Representations, Portfolios

Getting a single record

A bibliographic record can be fetched either by MMS ID:

$bib = $alma->bibs->get('990114012304702204');

or by barcode:

$bib = $alma->bibs->fromBarcode('92nf02526');

Note: This methods returns null if the barcode is not found, as does the two methods below.

There are also two lookup methods that use SRU search and require that you have an SRU client attached. The first lets you use a generic CQL query:

$bib = $alma->bibs->findOne('alma.all_for_ui="9788299308922"');

The second is a shorthand for looking up records from ISBN:

$bib = $alma->bibs->fromIsbn('9788299308922');

All the methods above returns either a single Bib record or null if not found.

The MARC21 record

The MARC21 record is available as $bib->record in the form of a php-marc Record object that extends File_MARC_Record from File_MARC, meaning you can use all File_MARC methods in addition to the convenience methods from php-marc.

Searching for records

If you have connected an SRU client (described above), you can search using CQL search syntax like so:

foreach ($alma->bibs->search('alma.dewey_decimal_class_number=530.12') as $bib) {
	$marcRecord = $bib->record;
	echo "$marcRecord->id: $marcRecord->title\n";
}

Getting linked record from network zone

If you have configured a network zone API key (see above), you can easily get the network zone record connected to the institution zone record like so:

$nzBib = $bib->getNzRecord();

Editing records

The MARC21 record can be edited using the File_MARC_Record interface (see File_MARC for documentation), and then saved back to Alma using the save() method on the Bib object. In the example below we delete a subject heading and add another:

$bib = $alma->bibs->get('990114012304702204');

foreach ($bib->record->getSubjects() as $subject) {
    if ($subject->vocabulary == 'noubomn' && (string) $subject == 'Boating with dogs') {
        $subject->delete();
    }
}

$bib->record->appendField(new File_MARC_Data_Field('650', [
    new File_MARC_Subfield('a', 'Boating with cats'),
    new File_MARC_Subfield('2', 'noubomn'),
], null, '0'));

$bib->save();

Holdings and items

The Bib object provides easy access to holdings through the holdings iterator:

$bib = $alma->bibs->get('990310361044702204');
foreach ($bib->holdings as $holding) {
    echo "{$holding->holding_id} {$holding->call_number}";
}

As with the Bib object, the MARC record for the Holding object is available either from the getRecord() method or the record property:

$holding = $alma->bibs['990310361044702204']->holdings['22102913020002204'];
$marcRecord = $holding->record;

Note: Editing holding records is not yet supported. Will be added in the future.

Items can be listed from holdings in the same manner as holdings can be fetched from bibs. Here's an example:

$bib = $alma->bibs->get('990310361044702204');
foreach ($bib->holdings as $holding) {
    foreach ($holding->items as $item) {
        echo "{$item->holding_id} {$item->pid} {$item->barcode} : ";
        echo "{$item->location->desc} {$item->call_number} : ";
        echo "{$item->base_status->desc} {$item->process_type->desc}";
        echo "\n";
    }
}

In this case, the client makes one request to fetch the list of holdings, and then one request per holding to fetch items.

Item by barcode

There is a special entrypoint to retrieve an item by barcode:

$item = $alma->items->fromBarcode('92nf02526');

Electronic portfolios and collections

Electronic portfolios and collections are available on the Bib object in the same way as holdings.

$bib = $alma->bibs->get('990310361044702204');
foreach ($bib->portfolios as $portfolio) {
    echo "{$portfolio->portfolio_id} {$portfolio->electronic_collection->service->link} ";
    echo "{$portfolio->electronic_collection->public_name}\n";
}
$bib = $alma->bibs->get('990310361044702204');
foreach ($bib->electronic_collections as $collection) {
    echo "{$collection->public_name}";
}

Digital representation and files

$bib = $alma->bibs->get('990310361044702204');
foreach ($bib->representations as $rep) {
    echo "{$rep->representation_id} {$rep->label}\n";
    foreach ($rep->files as $rep_file) {
        echo "{$rep_file->label} {$rep_file->thumbnail_url}\n";
    }
}

Users, loans, fees and requests

Note: Editing is not yet implemented.

Search

Example:

foreach ($alma->users->search('last_name~Heggø AND first_name~Dan') as $user) {
    echo "{$user->first_name} {$user->last_name} ({$user->primary_id})\n";
}

Loans

Example:

foreach ($user->loans as $loan) {
    echo "{$loan->due_date} {$loan->title}\n";
}

Note that $user->loans is an iterator. To get an array instead, you can use iterator_to_array($user->loans).

Fees

Example:

printf('Total: %s %s', $user->fees->total_sum, $user->fees->currency);

foreach ($user->fees as $fee) {
    echo "{$fee->type->value}\t{$fee->balance}\t{$fee->title}\n";
}

Requests

Example:

foreach ($user->requests as $request) {
    echo json_encode($request, JSON_PRETTY_PRINT);
}

Requests can also be retrieved from a Bib object or an Item object.

Analytics reports

To retrieve the results from a single report:

$report = $alma->analytics->get('/shared/Alma/Item Historical Events/Reports/Items went into temporary location');
foreach ($report as $row) {
    echo implode("\t", $row) . "\n";
}

The rows are returned using a generator that takes care of fetching more rows until the result set is depleted, so you don't have to think about continuation. If you only want a subset of the rows, you must take care of breaking out of the loop yourself.

Column names

The column names can be accessed through headers:

$report = $alma->analytics->get('/shared/Alma/Item Historical Events/Reports/Items went into temporary location');
foreach ($report->headers as $header) {
    echo "$header\n";
}

The column names can be accessed through can also be used to access columns for a given row:

foreach ($report as $row) {
    echo $row['Title'] . "\n";
}

If you would rather use other column names than the ones defined in Analytics it is also possible to override them by passing in an array of names that must follow the same order as the columns in the report:

$report = $alma->analytics->get(
    '/shared/Alma/Item Historical Events/Reports/Items went into temporary location',
    [
        'mms_id',
        'receiving_date',
    ]
);
foreach ($report->rows as $row) {
    echo $row->mms_id . ": " . $row->receiving_date . "\n";
}

Filters

The method also accepts a filter.

$report = $alma->analytics->get(
    '/shared/Alma/Item Historical Events/Reports/Items went into temporary location',
    [
        'mms_id',
        'receiving_date',
    ],
    '<sawx:expr op="greaterOrEqual" xsi:type="sawx:comparison"
                   xmlns:sawx="com.siebel.analytics.web/expression/v1.1"
                   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
       <sawx:expr xsi:type="sawx:sqlExpression">"Physical Item Details"."Receiving   Date"</sawx:expr>
       <sawx:expr xsi:type="sawx:sqlExpression">TIMESTAMPADD(SQL_TSI_DAY, -1, CURRENT_DATE)</sawx:expr>
    </sawx:expr>',
);
foreach ($report->rows as $row) {
    echo $row->mms_id . ": " . $row->receiving_date . "\n";
}

There isn't much official documentation on filters, and error responses from the API are generally not useful, but there's some helpful hints in this blog post.

My experience so far is that including a "is prompted" filter in the report is really needed. Otherwise the query returns a 400 No more rows to fetch (after a looong wait).

Furthermore, I've not been successful with pure SQL filters. In OBI you can convert a filter to SQL. The results looks like this:

<sawx:expr xmlns:saw="com.siebel.analytics.web/report/v1.1" xmlns:sawx="com.siebel.analytics.web/expression/v1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="sawx:sawx:sql">"Physical Item Details"."Receiving   Date" &gt;=  TIMESTAMPADD(SQL_TSI_DAY, -1, CURRENT_DATE)</sawx:expr>

But used with the API, the response is the same as if you forget to include an "is prompted" filter: a loong wait follow by a "400 No more rows to fetch".

Task lists

Lending requests

$library = $alma->conf->libraries['LIBRARY_CODE'];
$requests = $alma->taskLists->getLendingRequests($library, [
    'printed' => 'N',
    'status' => 'REQUEST_CREATED_LEND',
]);
foreach ($requests as $request) {
    echo "- {$request->request_id} {$request->status}\n";
}

Note: As of 2018-10-13, there is a bug preventing you from retrieving more than 100 lending requests.

Requested resources (pick from shelf)

$library = $alma->libraries['LIBRARY_CODE'];
$requests = $alma->taskLists->getRequestedResources($library, 'DEFAULT_CIRC_DESK', [
    'printed' => 'N',
]);
foreach ($requests as $request) {
    echo "- {$request->resource_metadata->title} {$request->resource_metadata->mms_id->value}\n";
}

Jobs

Listing jobs

To list all jobs and their instances:

foreach ($alma->jobs as $job) {
    echo "[{$job->id}] {$job->name} / {$job->description}\n";
    foreach ($job->instances as $instance) {
        echo "    [{$instance->id}] Status: {$instance->status->desc}\n";
    }
}

This is a generator, so you can start processing results before the full list has been retrieved (it fetches jobs in batches of 10).

Retrieving information about a specific job

$job = $alma->jobs['M43'];

Submitting a job

$instance = $alma->jobs['M43']->submit();

Automatic retries on errors

If the client receives a 429 (rate limiting) response from Alma, it will sleep for a short time (0.5 seconds by default) and retry the request a configurable number of times (10 by default), before giving up and throwing a Scriptotek\Alma\Exception\MaxNumberOfAttemptsExhausted. Both the max number of attempts and the sleep time can be configured:

$client->maxAttempts = 5;
$client->sleepTimeOnRetry = 3;  // seconds

If the client receives a 5XX server error, it will by default not retry the request, but this can be configured. This can be useful when retrieving large Analytics reports, which have a tendency to fail intermittently.

$client->maxAttemptsOnServerError = 10;
$client->sleepTimeOnServerError = 10;  // seconds

When the number of retries have been exhausted, a Scriptotek\Alma\Exception\RequestFailed exception is thrown.

Laravel integration

This project ships with an auto-discoverable service provider and facade. Run

$ php artisan vendor:publish --provider="Scriptotek\Alma\Laravel\ServiceProvider"

to create the config/alma.php configuration file.

If you are on Laravel 5.4 or older, you must manually add the service provider to the $providers array in your config/app.php:

Scriptotek\Alma\Laravel\ServiceProvider::class,

And the facade:

'Alma' => Scriptotek\Alma\Laravel\Facade::class,

Customizing the HTTP client stack

If the Laravel Service Container contains a PSR-18 HTTP Client bound to Psr\Http\Client\ClientInterface, Alma Client will use that implementation instead of instantiating its own HTTP client.

Here's an example service provider that registers a HTTP client with some middleware from php-http/client-common:

<?php

namespace App\Providers;

use Http\Client\Common\Plugin\ContentLengthPlugin;
use Http\Client\Common\Plugin\ErrorPlugin;
use Http\Client\Common\Plugin\RetryPlugin;
use Http\Client\Common\PluginClient;
use Http\Factory\Discovery\HttpClient;
use Illuminate\Support\ServiceProvider;
use Psr\Http\Client\ClientInterface;

class HttpServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton(ClientInterface::class, function($app) {
            return new PluginClient(
                HttpClient::client(),
                [
                    new ContentLengthPlugin(),
                    new RetryPlugin([
                        'retries' => 10,
                    ]),
                    new ErrorPlugin(),
                ]
            );
        });
    }
}

Future plans

  • Better support for editing, perhaps better integration with the php-marc package to provide a fluent editing interface:
$bib = $alma->bibs->get('990114012304702204');  // a Bib object
$bib->record->subjects->add([
	'term' => 'Boating with cats',
	'vocabulary' => noubomn'
]);
$bib->save()
  • Support for creating records and users:
$bib = new Bib();
$alma->bibs->store($bib);