├── .github └── workflows │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── config └── config.php ├── index.html ├── phpunit.xml ├── provides.json ├── src ├── Client.php ├── Exceptions │ ├── InvalidResponseException.php │ └── SruErrorException.php ├── ExplainResponse.php ├── Facades │ └── SruClient.php ├── Providers │ └── SruServiceProvider.php ├── Record.php ├── Records.php ├── Response.php ├── ResponseInterface.php └── SearchRetrieveResponse.php └── tests ├── ClientTest.php ├── ExplainResponseTest.php ├── RecordTest.php ├── RecordsTest.php ├── SearchRetrieveResponseTest.php └── TestCase.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | php_version: 12 | - "8.0" 13 | - "8.1" 14 | - "8.2" 15 | - "8.3" 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Setup PHP 19 | uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: ${{ matrix.php_version }} 22 | coverage: pcov 23 | tools: composer:v2 24 | 25 | - run: composer install 26 | 27 | - run: composer test 28 | 29 | - run: bash <(curl -s https://codecov.io/bash) 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /build/ 3 | composer.lock 4 | *.cache 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | (nothing yet) 10 | 11 | ## [0.7.2](https://github.com/scriptotek/php-sru-client/compare/v0.7.1...v0.7.2) - 2021-12-21 12 | 13 | - PHP version: minimum PHP 8.0 14 | 15 | ## [0.7.1](https://github.com/scriptotek/php-sru-client/compare/v0.7.0...v0.7.1) - 2020-07-13 16 | 17 | ### Changed 18 | 19 | - Added request URL to SruErrorException. 20 | 21 | ## [0.7.0] - 2019-04-03 22 | 23 | ### Changed 24 | - Update from httplug to PSR-17/PSR-18. With PSR-17 follows the requirement of a HTTP factory implementation. 25 | Run `composer require http-interop/http-factory-guzzle` to continue using sru-client as before. 26 | 27 | - Remove support for PHP 5.6 and 7.0 because we updated the tests to work with PHPUnit 7+ 28 | and can no longer run tests on PHP < 7.1. Also, PHP 5.6 and PHP 7.0 have now reached end-of-life. 29 | 30 | - Github: switched to [tag releases](https://github.com/scriptotek/php-sru-client/tags) 31 | 32 | ## [0.6.4] - 2019-03-06 33 | ### Fixed 34 | - Fix a few type hints. 35 | - Minor code style cleanups. 36 | 37 | ## [0.6.3] - 2017-08-01 38 | ### Added 39 | - Add support for setting custom headers. 40 | 41 | ### Changed 42 | - Don't throw `ClientErrorException` on 4XX responses, since some servers return 4XX responses whenever there is a diagnostic message, including zero result queries. Diagnostic messages are better handled by our `Response` class. 43 | 44 | ## [0.6.2] - 2017-08-01 45 | ### Added 46 | - Add `__toString()` method to `Record` to allow simple string serialization. 47 | 48 | ## [0.6.1] - 2017-08-01 49 | ### Changed 50 | - Update from quitesimplexmlelement 0.x to 1.x. 51 | 52 | ## [0.6.0] - 2017-07-01 53 | ### Changed 54 | - Replace hard dependency on Guzzle with HTTPlug client discovery. 55 | To continue use Guzzle, run `composer require php-http/guzzle6-adapter` 56 | 57 | [Unreleased]: https://github.com/scriptotek/php-sru-client/compare/v0.7.0...HEAD 58 | [0.7.0]: https://github.com/scriptotek/php-sru-client/compare/v0.6.4...v0.7.0 59 | [0.6.4]: https://github.com/scriptotek/php-sru-client/compare/v0.6.3...v0.6.4 60 | [0.6.3]: https://github.com/scriptotek/php-sru-client/compare/v0.6.2...v0.6.3 61 | [0.6.2]: https://github.com/scriptotek/php-sru-client/compare/v0.6.1...v0.6.2 62 | [0.6.1]: https://github.com/scriptotek/php-sru-client/compare/v0.6.0...v0.6.1 63 | [0.6.0]: https://github.com/scriptotek/php-sru-client/releases/tag/v0.6.0 64 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Dan Michael O. Heggø 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Coverage](https://img.shields.io/codecov/c/github/scriptotek/php-sru-client/master.svg?style=flat-square)](https://codecov.io/gh/scriptotek/php-sru-client) 3 | [![Code Quality](http://img.shields.io/scrutinizer/g/scriptotek/php-sru-client/master.svg?style=flat-square)](https://scrutinizer-ci.com/g/scriptotek/php-sru-client/?branch=master) 4 | [![Latest Stable Version](http://img.shields.io/packagist/v/scriptotek/sru-client.svg?style=flat-square)](https://packagist.org/packages/scriptotek/sru-client) 5 | [![Total Downloads](http://img.shields.io/packagist/dt/scriptotek/sru-client.svg?style=flat-square)](https://packagist.org/packages/scriptotek/sru-client) 6 | 7 | # php-sru-client 8 | 9 | Simple PHP package for making [Search/Retrieve via URL](http://www.loc.gov/standards/sru/) (SRU) requests and returning 10 | [QuiteSimpleXMLElement](//github.com/danmichaelo/quitesimplexmlelement) objects. 11 | The response object is an iterator, to support easy iteration over the results, 12 | abstracting away the process of making multiple requests. 13 | 14 | If you prefer a simple text response, you might have a look at 15 | the [php-sru-search](https://github.com/Zeitschriftendatenbank/php-sru-search) package. 16 | 17 | ## Install using Composer 18 | 19 | Use [Composer](https://getcomposer.org) to install sru-client with a HTTP library such as Guzzle: 20 | 21 | ```bash 22 | composer require scriptotek/sru-client php-http/guzzle6-adapter http-interop/http-factory-guzzle 23 | ``` 24 | 25 | We use [HTTP discovery](https://github.com/http-interop/http-factory-discovery) to discover 26 | [HTTP client](https://packagist.org/providers/psr/http-client-implementation) and 27 | [HTTP factory](https://packagist.org/providers/psr/http-factory-implementation) implementations, 28 | so Guzzle can be swapped with any other 29 | [PSR-17](https://www.php-fig.org/psr/psr-17/)/[PSR-18](https://www.php-fig.org/psr/psr-18/)-compatible library. 30 | 31 | ## Configuring the client 32 | 33 | ```php 34 | require_once('vendor/autoload.php'); 35 | use Scriptotek\Sru\Client as SruClient; 36 | 37 | $sru = new SruClient('http://bibsys-network.alma.exlibrisgroup.com/view/sru/47BIBSYS_NETWORK', [ 38 | 'schema' => 'marcxml', 39 | 'version' => '1.2', 40 | 'user-agent' => 'MyTool/0.1', 41 | ]); 42 | ``` 43 | 44 | ## Search and retrieve 45 | 46 | To get all the records matching a given CQL query: 47 | 48 | ```php 49 | $records = $sru->all('alma.title="Hello world"'); 50 | foreach ($records as $record) { 51 | echo "Got record " . $record->position . " of " . $records->numberOfRecords() . "\n"; 52 | // processRecord($record->data); 53 | } 54 | ``` 55 | 56 | where `$record` is an instance of [Record](//scriptotek.github.io/php-sru-client/api_docs/Scriptotek/Sru/Record.html) and `$record->data` is an instance of [QuiteSimpleXMLElement](https://github.com/danmichaelo/quitesimplexmlelement). 57 | 58 | The `all()` method takes care of continuation for you under the hood for you; 59 | the [Records](//scriptotek.github.io/php-sru-client/api_docs/Scriptotek/Sru/Records.html) generator 60 | continues to fetch records until the result set is depleted. A default batch size of 10 is used, 61 | but you can give any number supported by the server as a second argument to the `all()` method. 62 | 63 | If you query for some identifier, you can use the convenience method `first()`: 64 | 65 | ```php 66 | $record = $sru->first('alma.isbn="0415919118"'); 67 | ``` 68 | 69 | The result is a [Record](//scriptotek.github.io/php-sru-client/api_docs/Scriptotek/Sru/Record.html) 70 | object, or `null` if not found. 71 | 72 | 73 | ## Use explain to get information about servers 74 | 75 | ```php 76 | $urls = array( 77 | 'http://sru.bibsys.no/search/biblio', 78 | 'http://lx2.loc.gov:210/LCDB', 79 | 'http://services.d-nb.de/sru/zdb', 80 | 'http://api.libris.kb.se/sru/libris', 81 | ); 82 | 83 | foreach ($urls as $url) { 84 | 85 | $sru = new SruClient($url, [ 86 | 'version' => '1.1', 87 | 'user-agent' => 'MyTool/0.1' 88 | ]); 89 | 90 | try { 91 | $response = $sru->explain(); 92 | } catch (\Scriptotek\Sru\Exceptions\SruErrorException $e) { 93 | print 'ERROR: ' . $e->getMessage() . "\n"; 94 | continue; 95 | } 96 | 97 | printf("Host: %s:%d\n", $response->host, $response->port); 98 | printf(" Database: %s\n", $response->database->identifier); 99 | printf(" %s\n", $response->database->title); 100 | printf(" %s\n", $response->database->description); 101 | print " Indexes:\n"; 102 | foreach ($response->indexes as $idx) { 103 | printf(" - %s: %s\n", $idx->title, implode(' / ', $idx->maps)); 104 | } 105 | 106 | } 107 | ``` 108 | 109 | ## Laravel 5 integration 110 | 111 | Add the service provider to the `'providers'` array in `config/app.php`: 112 | 113 | Scriptotek\Sru\Providers\SruServiceProvider::class, 114 | 115 | Optionally, add the facade to the `'aliases'` array in the same file: 116 | 117 | 'SruClient' => Scriptotek\Sru\Facades\SruClient::class, 118 | 119 | To create the configuration file `config/sru.php`: 120 | 121 | $ php artisan vendor:publish --provider="Scriptotek\Sru\Providers\SruServiceProvider" 122 | 123 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scriptotek/sru-client", 3 | "type": "library", 4 | "description": "Package for making Search/Retrieve via URL requests and parse the responses", 5 | "homepage": "http://github.com/scriptotek/sru-client", 6 | "keywords": ["sru"], 7 | "require": { 8 | "php" : "^8.0", 9 | "danmichaelo/quitesimplexmlelement": "^1.0", 10 | "psr/http-client-implementation": "^1.0", 11 | "psr/http-factory-implementation": "^1.0", 12 | "http-interop/http-factory-discovery": "^1.4", 13 | "php-http/client-common": "^2.0" 14 | }, 15 | "require-dev": { 16 | "phpunit/phpunit": "^8.0 || ^9.0", 17 | "php-http/mock-client": "^1.0", 18 | "http-interop/http-factory-guzzle": "^1.0", 19 | "php-http/guzzle6-adapter": "^2.0" 20 | }, 21 | "license": "MIT", 22 | "authors": [ 23 | { 24 | "name": "Dan Michael O. Heggø", 25 | "email": "danmichaelo@gmail.com" 26 | } 27 | ], 28 | "autoload": { 29 | "psr-4": { 30 | "Scriptotek\\Sru\\": "src/" 31 | } 32 | }, 33 | "autoload-dev": { 34 | "psr-4": { 35 | "Scriptotek\\Sru\\": "tests/" 36 | } 37 | }, 38 | "scripts": { 39 | "test": "phpunit" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | 'URL-TO-ENDPOINT', 12 | 13 | /* 14 | |-------------------------------------------------------------------------- 15 | | Schema 16 | |-------------------------------------------------------------------------- 17 | | 18 | | Default schema to use. 19 | | 20 | */ 21 | 'schema' => 'marcxml', 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | Version 26 | |-------------------------------------------------------------------------- 27 | | 28 | | Default version to use. 29 | | 30 | */ 31 | 'version' => '1.1', 32 | 33 | /* 34 | |-------------------------------------------------------------------------- 35 | | User-Agent 36 | |-------------------------------------------------------------------------- 37 | | 38 | | Default user-agent to identify as. 39 | | 40 | */ 41 | 'user-agent' => 'MyLaravelSite/0.1', 42 | 43 | 44 | ); 45 | 46 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | tests/ 6 | 7 | 8 | 9 | 10 | src 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /provides.json: -------------------------------------------------------------------------------- 1 | { 2 | "providers": [ 3 | "Scriptotek\Sru\Providers\SruServiceProvider" 4 | ], 5 | "aliases": [ 6 | { 7 | "alias": "SruClient", 8 | "facade": "Scriptotek\Sru\Facades\SruClient" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /src/Client.php: -------------------------------------------------------------------------------- 1 | url = $url; 71 | $options = $options ?: []; 72 | 73 | $plugins = []; 74 | 75 | $this->schema = $options['schema'] ?? 'marcxml'; 76 | 77 | $this->version = $options['version'] ?? '1.1'; 78 | 79 | $this->headers = $options['headers'] ?? ['Accept' => 'application/xml']; 80 | 81 | if (isset($options['user-agent'])) { 82 | // legacy option 83 | $this->headers['User-Agent'] = $options['user-agent']; 84 | } 85 | 86 | if (isset($options['credentials'])) { 87 | $authentication = new BasicAuth($options['credentials'][0], $options['credentials'][1]); 88 | $plugins[] = new AuthenticationPlugin($authentication); 89 | } 90 | 91 | if (isset($options['proxy'])) { 92 | throw new \ErrorException('Not supported'); 93 | } 94 | 95 | $this->httpClient = new PluginClient($httpClient ?: HttpClient::client(), $plugins); 96 | $this->requestFactory = $requestFactory ?: HttpFactory::requestFactory(); 97 | } 98 | 99 | /** 100 | * Construct the URL for a CQL query 101 | * 102 | * @param string $cql The CQL query 103 | * @param int $start Start value in result set (optional) 104 | * @param int $count Number of records to request (optional) 105 | * @param array $extraParams Extra GET parameters 106 | * @return string 107 | */ 108 | public function urlTo(string $cql, int $start = 1, int $count = 10, array $extraParams = []): string 109 | { 110 | $qs = array( 111 | 'operation' => 'searchRetrieve', 112 | 'version' => $this->version, 113 | 'recordSchema' => $this->schema, 114 | 'maximumRecords' => $count, 115 | 'query' => $cql 116 | ); 117 | 118 | if ($start != 1) { 119 | // At least the BIBSYS SRU service, specifying startRecord results in 120 | // a less clear error message when there's no results 121 | $qs['startRecord'] = $start; 122 | } 123 | 124 | foreach ($extraParams as $key => $value) { 125 | $qs[$key] = $value; 126 | } 127 | 128 | return $this->url . '?' . http_build_query($qs); 129 | } 130 | 131 | /** 132 | * Perform a searchRetrieve request 133 | * 134 | * @param string $cql 135 | * @param int $start Start value in result set (optional) 136 | * @param int $count Number of records to request (optional) 137 | * @param array $extraParams Extra GET parameters 138 | * @return SearchRetrieveResponse 139 | *@deprecated 140 | */ 141 | public function search(string $cql, int $start = 1, int $count = 10, array $extraParams = []): SearchRetrieveResponse 142 | { 143 | $url = $this->urlTo($cql, $start, $count, $extraParams); 144 | $body = $this->request('GET', $url); 145 | 146 | return new SearchRetrieveResponse($body, $this, $url); 147 | } 148 | 149 | /** 150 | * Perform a searchRetrieve request and return an iterator over the records 151 | * 152 | * @param string $cql 153 | * @param int $batchSize Number of records to request per request 154 | * @param array $extraParams Extra GET parameters 155 | * @return Records 156 | */ 157 | public function all(string $cql, int $batchSize = 10, array $extraParams = []): Records 158 | { 159 | return new Records($cql, $this, $batchSize, $extraParams); 160 | } 161 | 162 | /** 163 | * Alias for `all()` 164 | * @param string $cql 165 | * @param int $batchSize 166 | * @param array $extraParams 167 | * @return Records 168 | *@deprecated 169 | */ 170 | public function records(string $cql, int $batchSize = 10, array $extraParams = []): Records 171 | { 172 | return $this->all($cql, $batchSize, $extraParams); 173 | } 174 | 175 | /** 176 | * Perform a searchRetrieve request and return first record 177 | * 178 | * @param string $cql 179 | * @param array $extraParams Extra GET parameters 180 | * @return ?Record 181 | */ 182 | public function first(string $cql, array $extraParams = []): ?Record 183 | { 184 | $recs = new Records($cql, $this, 1, $extraParams); 185 | return $recs->numberOfRecords() ? $recs->current() : null; 186 | } 187 | 188 | /** 189 | * Perform an explain request 190 | * 191 | * @return ExplainResponse 192 | */ 193 | public function explain(): ExplainResponse 194 | { 195 | $url = $this->url . '?' . http_build_query(array( 196 | 'operation' => 'explain', 197 | 'version' => $this->version, 198 | )); 199 | 200 | $body = $this->request('GET', $url); 201 | 202 | return new ExplainResponse($body, $this, $url); 203 | } 204 | 205 | /** 206 | * @param string $method 207 | * @param string $url 208 | * @return string 209 | */ 210 | public function request(string $method, string $url): string 211 | { 212 | $request = $this->requestFactory->createRequest($method, $url, $this->headers); 213 | $response = $this->httpClient->sendRequest($request); 214 | 215 | if ($response->getStatusCode() >= 500 && $response->getStatusCode() < 600) { 216 | throw new ServerErrorException($response->getReasonPhrase(), $request, $response); 217 | } 218 | 219 | return (string) $response->getBody(); 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /src/Exceptions/InvalidResponseException.php: -------------------------------------------------------------------------------- 1 | uri = $uri; 19 | $this->requestUrl = $requestUrl; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ExplainResponse.php: -------------------------------------------------------------------------------- 1 | indexes = []; 36 | 37 | if (is_null($this->response)) { 38 | return; 39 | } 40 | $explain = $this->response->first('/srw:explainResponse/srw:record/srw:recordData/exp:explain'); 41 | if (!$explain) { 42 | return; 43 | } 44 | 45 | $this->parseExplainResponse($explain); 46 | } 47 | 48 | protected function parseExplainResponse(QuiteSimpleXMLElement $node) 49 | { 50 | $serverInfo = $node->first('exp:serverInfo'); 51 | $dbInfo = $node->first('exp:databaseInfo'); 52 | $indexInfo = $node->first('exp:indexInfo'); 53 | 54 | $this->host = $serverInfo->text('exp:host'); 55 | $this->port = (int) $serverInfo->text('exp:port'); 56 | $this->database = new \StdClass(); 57 | $this->database->identifier = $serverInfo->text('exp:database'); 58 | $this->database->title = $dbInfo->text('exp:title'); 59 | $this->database->description = $dbInfo->text('exp:description'); 60 | 61 | foreach ($indexInfo->xpath('exp:index') as $index) { 62 | $ind = new \StdClass(); 63 | $ind->scan = ($index->attr('scan') == 'true'); 64 | $ind->search = ($index->attr('search') == 'true'); 65 | $ind->sort = ($index->attr('sort') == 'true'); 66 | $ind->title = $index->text('exp:title'); 67 | $ind->maps = []; 68 | foreach ($index->xpath('exp:map') as $map) { 69 | $set = $map->first('exp:name')->attr('set'); 70 | $name = $map->text('exp:name'); 71 | $ind->maps[] = $set . '.' . $name; 72 | } 73 | $this->indexes[] = $ind; 74 | } 75 | 76 | // TODO 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Facades/SruClient.php: -------------------------------------------------------------------------------- 1 | publishes(array( 25 | __DIR__.'/../../config/config.php' => config_path('sru.php') 26 | )); 27 | } 28 | 29 | /** 30 | * Register the service provider. 31 | * 32 | * @return void 33 | */ 34 | public function register() 35 | { 36 | $this->mergeConfigFrom( 37 | __DIR__.'/../../config/config.php', 38 | 'sru' 39 | ); 40 | $this->app->singleton(SruClient::class, function ($app) { 41 | return new SruClient($app['config']->get('sru.endpoint'), $app['config']->get('sru')); 42 | }); 43 | } 44 | 45 | /** 46 | * Get the services provided by the provider. 47 | * 48 | * @return array 49 | */ 50 | public function provides() 51 | { 52 | return [SruClient::class]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Record.php: -------------------------------------------------------------------------------- 1 | 25 | {{recordSchema}} 26 | {{recordPacking}} 27 | {{position}} 28 | {{data}} 29 | '; 30 | 31 | /** 32 | * Create a new record 33 | * @param QuiteSimpleXMLElement $doc 34 | */ 35 | public function __construct(QuiteSimpleXMLElement $doc) 36 | { 37 | $this->position = intval($doc->text('./srw:recordPosition')); 38 | $this->packing = $doc->text('./srw:recordPacking'); 39 | $this->schema = $doc->text('./srw:recordSchema'); 40 | $this->data = $doc->first('./srw:recordData'); 41 | } 42 | 43 | /** 44 | * @param int $position 45 | * @param string|QuiteSimpleXMLElement $data 46 | * @param string $recordSchema 47 | * @param string $recordPacking 48 | * @return Record 49 | */ 50 | public static function make( 51 | int $position, 52 | string|QuiteSimpleXMLElement $data, 53 | string $recordSchema='marcxchange', 54 | string $recordPacking='xml' 55 | ): Record { 56 | $record = str_replace( 57 | array('{{position}}', '{{data}}', '{{recordSchema}}', '{{recordPacking}}'), 58 | array($position, $data, $recordSchema, $recordPacking), 59 | self::$recordTpl 60 | ); 61 | 62 | return new Record(QuiteSimpleXMLElement::make($record, Response::$nsPrefixes)); 63 | } 64 | 65 | /** 66 | * Get the record data as a string. 67 | * 68 | * @return string 69 | */ 70 | public function __toString() 71 | { 72 | $nodes = $this->data->xpath('./child::*'); 73 | if (count($nodes) == 1) { 74 | return $nodes[0]->asXML(); 75 | } elseif (count($nodes) > 1) { 76 | throw new \RuntimeException('recordData contains more than one node!'); 77 | } 78 | 79 | return $this->data->text(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Records.php: -------------------------------------------------------------------------------- 1 | position = 1; 42 | $this->data = []; 43 | $this->count = $count; // number of records per request 44 | $this->extraParams = $extraParams; 45 | $this->cql = $cql; 46 | $this->client = $client; 47 | $this->fetchMore(); 48 | } 49 | 50 | /** 51 | * Return the number of records 52 | */ 53 | public function numberOfRecords(): int 54 | { 55 | return $this->lastResponse->numberOfRecords; 56 | } 57 | 58 | /** 59 | * Fetch more records from the service 60 | */ 61 | private function fetchMore(): void 62 | { 63 | $url = $this->client->urlTo($this->cql, $this->position, $this->count, $this->extraParams); 64 | $body = $this->client->request('GET', $url); 65 | $this->lastResponse = new SearchRetrieveResponse($body); 66 | $this->data = $this->lastResponse->records; 67 | 68 | if (count($this->data) != 0 && $this->data[0]->position != $this->position) { 69 | throw new Exceptions\InvalidResponseException( 70 | 'Wrong index of first record in result set. ' 71 | . 'Expected: ' .$this->position . ', got: ' . $this->data[0]->position 72 | ); 73 | } 74 | } 75 | 76 | /** 77 | * Return the current element 78 | */ 79 | public function current(): Record 80 | { 81 | return $this->data[0]; 82 | } 83 | 84 | /** 85 | * Return the key of the current element 86 | */ 87 | public function key(): int 88 | { 89 | return $this->position; 90 | } 91 | 92 | /** 93 | * Rewind the Iterator to the first element 94 | */ 95 | public function rewind(): void 96 | { 97 | if ($this->position != 1) { 98 | $this->position = 1; 99 | $this->data = []; 100 | $this->fetchMore(); 101 | } 102 | } 103 | 104 | /** 105 | * Move forward to next element 106 | */ 107 | public function next(): void 108 | { 109 | if (count($this->data) > 0) { 110 | array_shift($this->data); 111 | } 112 | ++$this->position; 113 | 114 | if ($this->position > $this->numberOfRecords()) { 115 | return; 116 | } 117 | 118 | if (count($this->data) == 0) { 119 | $this->fetchMore(); 120 | } 121 | } 122 | 123 | /** 124 | * Check if current position is valid 125 | */ 126 | public function valid(): bool 127 | { 128 | return count($this->data) != 0; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Response.php: -------------------------------------------------------------------------------- 1 | 'http://www.loc.gov/zing/srw/', 14 | 'exp' => 'http://explain.z3950.org/dtd/2.0/', 15 | 'd' => 'http://www.loc.gov/zing/srw/diagnostic/', 16 | 'marc' => 'http://www.loc.gov/MARC21/slim', 17 | ); 18 | 19 | public static array $errorMessages = array( 20 | 'info:srw/diagnostic/1/1' => 'General system error', 21 | 'info:srw/diagnostic/1/2' => 'System temporarily unavailable', 22 | 'info:srw/diagnostic/1/3' => 'Authentication error', 23 | 'info:srw/diagnostic/1/4' => 'Unsupported operation', 24 | 'info:srw/diagnostic/1/5' => 'Unsupported version', 25 | 'info:srw/diagnostic/1/6' => 'Unsupported parameter value', 26 | 'info:srw/diagnostic/1/7' => 'Mandatory parameter not supplied', 27 | 'info:srw/diagnostic/1/8' => 'Unsupported parameter', 28 | 'info:srw/diagnostic/1/10' => 'Query syntax error', 29 | 'info:srw/diagnostic/1/12' => 'Too many characters in query', 30 | 'info:srw/diagnostic/1/13' => 'Invalid or unsupported use of parentheses', 31 | 'info:srw/diagnostic/1/14' => 'Invalid or unsupported use of quotes', 32 | 'info:srw/diagnostic/1/15' => 'Unsupported context set', 33 | 'info:srw/diagnostic/1/16' => 'Unsupported index', 34 | 'info:srw/diagnostic/1/18' => 'Unsupported combination of indexes', 35 | 'info:srw/diagnostic/1/19' => 'Unsupported relation', 36 | 'info:srw/diagnostic/1/20' => 'Unsupported relation modifier', 37 | 'info:srw/diagnostic/1/21' => 'Unsupported combination of relation modifers', 38 | 'info:srw/diagnostic/1/22' => 'Unsupported combination of relation and index', 39 | 'info:srw/diagnostic/1/23' => 'Too many characters in term', 40 | 'info:srw/diagnostic/1/24' => 'Unsupported combination of relation and term', 41 | 'info:srw/diagnostic/1/26' => 'Non special character escaped in term', 42 | 'info:srw/diagnostic/1/27' => 'Empty term unsupported', 43 | 'info:srw/diagnostic/1/28' => 'Masking character not supported', 44 | 'info:srw/diagnostic/1/29' => 'Masked words too short', 45 | 'info:srw/diagnostic/1/30' => 'Too many masking characters in term', 46 | 'info:srw/diagnostic/1/31' => 'Anchoring character not supported', 47 | 'info:srw/diagnostic/1/32' => 'Anchoring character in unsupported position', 48 | 'info:srw/diagnostic/1/33' => 'Combination of proximity/adjacency and masking characters not supported', 49 | 'info:srw/diagnostic/1/34' => 'Combination of proximity/adjacency and anchoring characters not supported', 50 | 'info:srw/diagnostic/1/35' => 'Term contains only stopwords', 51 | 'info:srw/diagnostic/1/36' => 'Term in invalid format for index or relatio', 52 | 'info:srw/diagnostic/1/37' => 'Unsupported boolean operator', 53 | 'info:srw/diagnostic/1/38' => 'Too many boolean operators in query', 54 | 'info:srw/diagnostic/1/39' => 'Proximity not supported', 55 | 'info:srw/diagnostic/1/40' => 'Unsupported proximity relation', 56 | 'info:srw/diagnostic/1/41' => 'Unsupported proximity distance', 57 | 'info:srw/diagnostic/1/42' => 'Unsupported proximity unit', 58 | 'info:srw/diagnostic/1/43' => 'Unsupported proximity ordering', 59 | 'info:srw/diagnostic/1/44' => 'Unsupported combination of proximity modifiers', 60 | 'info:srw/diagnostic/1/46' => 'Unsupported boolean modifier', 61 | 'info:srw/diagnostic/1/47' => 'Cannot process query; reason unknown', 62 | 'info:srw/diagnostic/1/48' => 'Query feature unsupported', 63 | 'info:srw/diagnostic/1/49' => 'Masking character in unsupported position', 64 | 'info:srw/diagnostic/1/50' => 'Result sets not supported', 65 | 'info:srw/diagnostic/1/51' => 'Result set does not exist', 66 | 'info:srw/diagnostic/1/52' => 'Result set temporarily unavailable', 67 | 'info:srw/diagnostic/1/53' => 'Result sets only supported for retrieval', 68 | 'info:srw/diagnostic/1/55' => 'Combination of result sets with search terms not supported', 69 | 'info:srw/diagnostic/1/58' => 'Result set created with unpredictable partial results available', 70 | 'info:srw/diagnostic/1/59' => 'Result set created with valid partial results available', 71 | 'info:srw/diagnostic/1/60' => 'Result set not created: too many matching records', 72 | 'info:srw/diagnostic/1/61' => 'First record position out of range', 73 | 'info:srw/diagnostic/1/64' => 'Record temporarily unavailable', 74 | 'info:srw/diagnostic/1/65' => 'Record does not exist', 75 | 'info:srw/diagnostic/1/66' => 'Unknown schema for retrieval', 76 | 'info:srw/diagnostic/1/67' => 'Record not available in this schema', 77 | 'info:srw/diagnostic/1/68' => 'Not authorised to send record', 78 | 'info:srw/diagnostic/1/69' => 'Not authorised to send record in this schema', 79 | 'info:srw/diagnostic/1/70' => 'Record too large to send', 80 | 'info:srw/diagnostic/1/71' => 'Unsupported record packing', 81 | 'info:srw/diagnostic/1/72' => 'XPath retrieval unsupported', 82 | 'info:srw/diagnostic/1/73' => 'XPath expression contains unsupported feature', 83 | 'info:srw/diagnostic/1/74' => 'Unable to evaluate XPath expression', 84 | 'info:srw/diagnostic/1/80' => 'Sort not supported', 85 | 'info:srw/diagnostic/1/82' => 'Unsupported sort sequence', 86 | 'info:srw/diagnostic/1/83' => 'Too many records to sort', 87 | 'info:srw/diagnostic/1/84' => 'Too many sort keys to sort', 88 | 'info:srw/diagnostic/1/86' => 'Cannot sort: incompatible record formats', 89 | 'info:srw/diagnostic/1/87' => 'Unsupported schema for sort', 90 | 'info:srw/diagnostic/1/88' => 'Unsupported path for sort', 91 | 'info:srw/diagnostic/1/89' => 'Path unsupported for schema', 92 | 'info:srw/diagnostic/1/90' => 'Unsupported direction', 93 | 'info:srw/diagnostic/1/91' => 'Unsupported case', 94 | 'info:srw/diagnostic/1/92' => 'Unsupported missing value action', 95 | 'info:srw/diagnostic/1/93' => 'Sort ended due to missing value', 96 | 'info:srw/diagnostic/1/94' => 'Sort spec included both in query and protocol: query prevails', 97 | 'info:srw/diagnostic/1/95' => 'Sort spec included both in query and protocol: protocol prevails', 98 | 'info:srw/diagnostic/1/96' => 'Sort spec included both in query and protocol: error', 99 | 'info:srw/diagnostic/1/110' => 'Stylesheets not supported', 100 | 'info:srw/diagnostic/1/120' => 'Response position out of range', 101 | 'info:srw/diagnostic/1/130' => 'Too many terms matched by masked query term', 102 | ); 103 | 104 | /** @var string Raw XML response */ 105 | protected string $rawResponse = ''; 106 | 107 | /** @var QuiteSimpleXMLElement|null XML response */ 108 | protected ?QuiteSimpleXMLElement $response = null; 109 | 110 | /** @var Client|null Reference to SRU client object */ 111 | protected ?Client $client = null; 112 | 113 | /** @var string|null SRU protocol version */ 114 | public ?string $version = null; 115 | 116 | /** 117 | * Create a new response 118 | * 119 | * @param string|null $text Raw XML response 120 | * @param Client|null $client SRU client reference (optional) 121 | * @param string|null $url 122 | */ 123 | public function __construct(string $text = null, Client &$client = null, string $url = null) 124 | { 125 | $this->client = $client; 126 | 127 | if (!is_null($text)) { 128 | $this->initializeFromText($text, $url); 129 | } 130 | } 131 | 132 | protected function initializeFromText($text, $url) 133 | { 134 | // Fix missing namespace in Alma records: 135 | $text = str_replace('', '', $text); 136 | 137 | $this->rawResponse = $text; 138 | 139 | // Throws Danmichaelo\QuiteSimpleXMLElement\InvalidXMLException on invalid xml 140 | $this->response = QuiteSimpleXMLElement::make($text, self::$nsPrefixes); 141 | 142 | $this->version = $this->response->text('/srw:*/srw:version'); 143 | 144 | $this->handleDiagnostic($url, $this->response->first('/srw:*/srw:diagnostics/d:diagnostic')); 145 | } 146 | 147 | protected function handleDiagnostic($url, QuiteSimpleXMLElement $node = null) 148 | { 149 | if (is_null($node)) { 150 | return; 151 | } 152 | 153 | // Only the 'uri' field is required, 'message' and 'details' are optional 154 | $uri = $node->text('d:uri'); 155 | if (strlen($uri)) { 156 | $msg = $node->text('d:message'); 157 | $details = $node->text('d:details'); 158 | if (empty($msg)) { 159 | $msg = self::$errorMessages[$uri] ?? 'Unknown error'; 160 | } 161 | if (!empty($details)) { 162 | $msg .= ' (' . $details . ')'; 163 | } 164 | throw new Exceptions\SruErrorException($msg, $uri, $url); 165 | } 166 | } 167 | 168 | /** 169 | * Get the raw xml response 170 | * 171 | * @return string 172 | */ 173 | public function asXml(): string 174 | { 175 | return $this->rawResponse; 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/ResponseInterface.php: -------------------------------------------------------------------------------- 1 | response)) { 34 | return; 35 | } 36 | 37 | $this->numberOfRecords = (int) $this->response->text('/srw:searchRetrieveResponse/srw:numberOfRecords'); 38 | $this->nextRecordPosition = (int) $this->response->text('/srw:searchRetrieveResponse/srw:nextRecordPosition') ?: null; 39 | 40 | // The server may echo the request back to the client along with the response 41 | $this->query = $this->response->text('/srw:searchRetrieveResponse/srw:echoedSearchRetrieveRequest/srw:query') ?: null; 42 | 43 | $this->records = []; 44 | foreach ($this->response->xpath('/srw:searchRetrieveResponse/srw:records/srw:record') as $record) { 45 | $this->records[] = new Record($record); 46 | } 47 | } 48 | 49 | /** 50 | * Request next batch of records in the result set, or return null if we're at the end of the set 51 | * 52 | * @return Response|null 53 | */ 54 | public function next(): ?Response 55 | { 56 | if (is_null($this->client)) { 57 | throw new \Exception('No client reference passed to response'); 58 | } 59 | if (is_null($this->query)) { 60 | throw new \Exception('No query available'); 61 | } 62 | if (is_null($this->nextRecordPosition)) { 63 | return null; 64 | } 65 | return $this->client->search($this->query, $this->nextRecordPosition, count($this->records)); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/ClientTest.php: -------------------------------------------------------------------------------- 1 | 13 | 14 | '; 15 | 16 | protected $simple_explain_response = ' 17 | 18 | '; 19 | 20 | public function testUrlTo() 21 | { 22 | $sru1 = new Client($this->url); 23 | $expectedUrl1 = $this->url . '?operation=searchRetrieve&version=1.1&recordSchema=marcxml&maximumRecords=10&query=isbn%3D123'; 24 | $expectedUrl2 = $this->url . '?operation=searchRetrieve&version=1.1&recordSchema=marcxml&maximumRecords=50&query=isbn%3D123&startRecord=2'; 25 | $expectedUrl5 = $this->url . '?operation=searchRetrieve&version=1.1&recordSchema=marcxml&maximumRecords=10&query=isbn%3D123&httpAccept=application%2Fxml'; 26 | 27 | $sru3 = new Client($this->url, array('schema' => 'CUSTOMSCHEMA')); 28 | $expectedUrl3 = $this->url . '?operation=searchRetrieve&version=1.1&recordSchema=CUSTOMSCHEMA&maximumRecords=10&query=isbn%3D123'; 29 | 30 | $sru4 = new Client($this->url, array('version' => '0.9')); 31 | $expectedUrl4 = $this->url . '?operation=searchRetrieve&version=0.9&recordSchema=marcxml&maximumRecords=10&query=isbn%3D123'; 32 | 33 | $this->assertEquals($expectedUrl1, $sru1->urlTo('isbn=123')); 34 | $this->assertEquals($expectedUrl2, $sru1->urlTo('isbn=123', 2, 50)); 35 | $this->assertEquals($expectedUrl3, $sru3->urlTo('isbn=123')); 36 | $this->assertEquals($expectedUrl4, $sru4->urlTo('isbn=123')); 37 | $this->assertEquals($expectedUrl5, $sru1->urlTo('isbn=123', 1, 10, array('httpAccept' => 'application/xml'))); 38 | } 39 | 40 | public function testSearch() 41 | { 42 | $http = $this->httpMockWithResponses($this->simple_response); 43 | $sru = new Client($this->url, null, $http); 44 | 45 | $this->assertXmlStringEqualsXmlString( 46 | $this->simple_response, 47 | $sru->search('test')->asXml() 48 | ); 49 | } 50 | 51 | public function testNext() 52 | { 53 | $cql = 'dc.title="Joda jada isjda"'; 54 | 55 | $http = new MockHttp(); 56 | $http->addResponse( 57 | (new HttpResponse()) 58 | ->withBody(stream_for(' 59 | 63 | 1.1 64 | 3 65 | 66 | 67 | marcxchange 68 | xml 69 | 1 70 | Record 1 71 | 72 | 73 | marcxchange 74 | xml 75 | 2 76 | Record 2 77 | 78 | 79 | 3 80 | 81 | searchRetrieve 82 | 1.1 83 | \' . $cql . \' 84 | 1 85 | 2 86 | marcxchange 87 | 88 | 89 | 2014-03-28T12:09:50Z 90 | 91 | 92 | ') 93 | ) 94 | ); 95 | 96 | $sru = new Client($this->url, null, $http); 97 | $response = $sru->search($cql); 98 | $this->assertCount(2, $response->records); 99 | 100 | 101 | $http->addResponse( 102 | (new HttpResponse()) 103 | ->withBody(stream_for(' 104 | 108 | 1.1 109 | 3 110 | 111 | 112 | marcxchange 113 | xml 114 | 3 115 | Record 3 116 | 117 | 118 | 119 | searchRetrieve 120 | 1.1 121 | ' . $cql . ' 122 | 3 123 | 2 124 | marcxchange 125 | 126 | 127 | 2014-03-28T12:09:50Z 128 | 129 | 130 | ')) 131 | ); 132 | 133 | $response = $response->next(); 134 | $this->assertCount(1, $response->records); 135 | 136 | $response = $response->next(); 137 | $this->assertNull($response); 138 | } 139 | 140 | public function testHttpHeaders() 141 | { 142 | $sru1 = new Client($this->url, array( 143 | 'user-agent' => 'Blablabla/0.1', 144 | )); 145 | 146 | $opts = $sru1->headers; 147 | 148 | $this->assertEquals('application/xml', $opts['Accept']); 149 | $this->assertEquals('Blablabla/0.1', $opts['User-Agent']); 150 | } 151 | 152 | public function testRecords() 153 | { 154 | $http = $this->httpMockWithResponses($this->makeDummyResponse(1)); 155 | 156 | $sru1 = new Client($this->url, [], $http); 157 | $r = $sru1->records('test', 1); 158 | 159 | $this->assertInstanceOf('Scriptotek\Sru\Records', $r); 160 | } 161 | 162 | public function testExplain() 163 | { 164 | $http = $this->httpMockWithResponses($this->simple_explain_response); 165 | $sru = new Client($this->url, null, $http); 166 | $exp = $sru->explain(); 167 | 168 | $this->assertInstanceOf('Scriptotek\Sru\ExplainResponse', $exp); 169 | 170 | $this->assertXmlStringEqualsXmlString( 171 | $this->simple_explain_response, 172 | $exp->asXml() 173 | ); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /tests/ExplainResponseTest.php: -------------------------------------------------------------------------------- 1 | 10 | 11 | 1.2 12 | 13 | xml 14 | http://explain.z3950.org/dtd/2.0/ 15 | 16 | 17 | 18 | sru.bibsys.no 19 | 80 20 | biblio 21 | 22 | 23 | BIBSYS Union Catalogue 24 | SRU access to the BIBSYS Union Catalogue. 25 | support@bibsys.no 26 | It is prohibited to use this service for bulk downloading of records. 27 | 28 | http://www.bibsys.no/images/logo/bibsys_logo_medium.jpg 29 | 30 | 31 | 32 | Sat Apr 26 16:47:43 CEST 2014 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | Identifiserer en bibliografisk enhet 43 | 44 | objektid 45 | 46 | 47 | 48 | An unambiguous reference to the resource within a given context. 49 | 50 | identifier 51 | 52 | 53 | 54 | nlm nummer 55 | 56 | nlm 57 | 58 | 59 | 60 | institusjonsspesifikke emneord 61 | 62 | ubtsy 63 | 64 | 65 | 66 | en plassering 67 | 68 | geografisk-emneord 69 | 70 | 71 | 72 | 73 | <map> 74 | <name set="bs">antallinstrumenter</name> 75 | </map> 76 | </index> 77 | <index scan="false" search="true" sort="true"> 78 | <title>intern 79 | 80 | issn-annen-manifestasjon 81 | 82 | 83 | ismn-annen-manifestasjon 84 | 85 | 86 | isbn-annen-manifestasjon 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | = 96 | marcxchange 97 | 10 98 | 50 99 | 100 | 101 | 102 | 103 | '); 104 | 105 | $this->assertEquals('1.2', $res->version); 106 | $this->assertEquals('sru.bibsys.no', $res->host); 107 | $this->assertEquals(80, $res->port); 108 | $this->assertEquals('biblio', $res->database->identifier); 109 | $this->assertEquals('BIBSYS Union Catalogue', $res->database->title); 110 | $this->assertEquals('SRU access to the BIBSYS Union Catalogue.', $res->database->description); 111 | $this->assertCount(7, $res->indexes); 112 | $this->assertFalse($res->indexes[0]->scan); 113 | $this->assertTrue($res->indexes[0]->sort); 114 | $this->assertTrue($res->indexes[0]->search); 115 | $this->assertEquals('Identifiserer en bibliografisk enhet', $res->indexes[0]->title); 116 | $this->assertEquals('bs.objektid', $res->indexes[0]->maps[0]); 117 | } 118 | 119 | public function testCanBeInitializedWithoutAnyData() 120 | { 121 | $res = new ExplainResponse; 122 | $this->assertNull($res->host); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /tests/RecordTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf('Scriptotek\Sru\Record', $record); 9 | $this->assertEquals(29, $record->position); 10 | } 11 | 12 | public function testToString() { 13 | $record = Record::make(29, 'Hello world'); 14 | 15 | $this->assertEquals('Hello world', (string) $record); 16 | } 17 | 18 | public function testXmlToString() { 19 | $record = Record::make(29, 'world'); 20 | 21 | $this->assertEquals('world', (string) $record); 22 | } 23 | 24 | public function testNamespacedXmlToString() { 25 | $record = Record::make(29, 'Test'); 26 | 27 | $this->assertEquals('Test', (string) $record); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/RecordsTest.php: -------------------------------------------------------------------------------- 1 | makeDummyResponse($n); 11 | $http = $this->httpMockWithResponses([$response, $response]); 12 | 13 | $client = new Client($uri, [], $http); 14 | $records = new Records($cql, $client, 10); 15 | $this->assertEquals(8, $records->numberOfRecords()); 16 | $records->rewind(); 17 | 18 | $this->assertEquals(1, $records->key()); 19 | $this->assertTrue($records->valid()); 20 | $records->next(); 21 | $records->next(); 22 | $this->assertEquals(3, $records->key()); 23 | $this->assertTrue($records->valid()); 24 | $records->rewind(); 25 | $this->assertEquals(1, $records->key()); 26 | $this->assertTrue($records->valid()); 27 | 28 | $i = 0; 29 | foreach ($records as $rec) { 30 | $i++; 31 | } 32 | $this->assertEquals($n, $i); 33 | } 34 | 35 | public function testRepeatSameResponse() 36 | { 37 | // Result set contains two records 38 | $response = $this->makeDummyResponse(2, array('maxRecords' => 1)); 39 | 40 | $http = $this->httpMockWithResponses([$response, $response]); 41 | $uri = 'http://localhost'; 42 | $cql = 'dummy'; 43 | 44 | // Request only one record in each request 45 | $client = new Client($uri, [], $http); 46 | $rec = new Records($cql, $client, 1); 47 | 48 | // Jumping to position 2 should call fetchMore() and throw 49 | // an InvalidResponseException on getting the same response 50 | // as we got for position 1 51 | $this->expectException(\Scriptotek\Sru\Exceptions\InvalidResponseException::class); 52 | $rec->next(); 53 | } 54 | 55 | public function testMultipleRequests() 56 | { 57 | $nrecs = 5; 58 | 59 | $responses = array( 60 | $this->makeDummyResponse($nrecs, array('startRecord' => 1, 'maxRecords' => 2)), 61 | $this->makeDummyResponse($nrecs, array('startRecord' => 3, 'maxRecords' => 2)), 62 | $this->makeDummyResponse($nrecs, array('startRecord' => 5, 'maxRecords' => 2)) 63 | ); 64 | 65 | $http = $this->httpMockWithResponses($responses); 66 | $uri = 'http://localhost'; 67 | $cql = 'dummy'; 68 | 69 | $client = new Client($uri, [], $http); 70 | $records = new Records($cql, $client, 10); 71 | 72 | $records->rewind(); 73 | foreach (range(1, $nrecs) as $n) { 74 | $this->assertEquals($n, $records->key()); 75 | $this->assertTrue($records->valid()); 76 | $this->assertEquals($n, $records->current()->position); 77 | $records->next(); 78 | } 79 | $this->assertFalse($records->valid()); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tests/SearchRetrieveResponseTest.php: -------------------------------------------------------------------------------- 1 | 10 | 14 | 1.1 15 | 1 16 | 17 | 18 | marcxchange 19 | xml 20 | 1 21 | Record 1 22 | 23 | 24 | 25 | searchRetrieve 26 | 1.1 27 | bs.avdelingsamling = "urealastr" AND bs.lokal-klass = "k C11?" 28 | 1 29 | 2 30 | marcxchange 31 | 32 | 33 | 2014-03-28T12:09:50Z 34 | 35 | '); 36 | 37 | $this->assertEquals('1.1', $res->version); 38 | $this->assertEquals(1, $res->numberOfRecords); 39 | $this->assertNull($res->nextRecordPosition); 40 | 41 | $this->assertCount(1, $res->records); 42 | $this->assertEquals(1, $res->records[0]->position); 43 | $this->assertEquals('marcxchange', $res->records[0]->schema); 44 | $this->assertEquals('xml', $res->records[0]->packing); 45 | $this->assertEquals('Record 1', $res->records[0]->data); 46 | } 47 | 48 | public function testMultipleRecordsResult() 49 | { 50 | $res = new SearchRetrieveResponse(' 51 | 55 | 1.1 56 | 303 57 | 58 | 59 | marcxchange 60 | xml 61 | 1 62 | Record 1 63 | 64 | 65 | marcxchange 66 | xml 67 | 2 68 | Record 2 69 | 70 | 71 | 3 72 | 73 | searchRetrieve 74 | 1.1 75 | bs.avdelingsamling = "urealastr" AND bs.lokal-klass = "k C11?" 76 | 1 77 | 2 78 | marcxchange 79 | 80 | 81 | 2014-03-28T12:09:50Z 82 | 83 | '); 84 | 85 | $this->assertEquals('1.1', $res->version); 86 | $this->assertEquals(303, $res->numberOfRecords); 87 | $this->assertEquals(3, $res->nextRecordPosition); 88 | 89 | $this->assertCount(2, $res->records); 90 | $this->assertEquals(1, $res->records[0]->position); 91 | $this->assertEquals('marcxchange', $res->records[0]->schema); 92 | $this->assertEquals('xml', $res->records[0]->packing); 93 | $this->assertEquals('Record 1', $res->records[0]->data); 94 | } 95 | 96 | public function testErrorWithDetails() 97 | { 98 | $this->expectException(\Scriptotek\Sru\Exceptions\SruErrorException::class); 99 | $this->expectExceptionMessage("Unknown schema for retrieval (Invalid parameter: 'marcxml' for service: 'biblio'"); 100 | 101 | $res = new SearchRetrieveResponse(' 102 | 1.1 103 | 0 104 | 105 | 106 | info:srw/diagnostic/1/66 107 |
Invalid parameter: \'marcxml\' for service: \'biblio\'
108 |
109 |
110 |
'); 111 | } 112 | 113 | public function testErrorWithoutDetails() 114 | { 115 | $this->expectException(\Scriptotek\Sru\Exceptions\SruErrorException::class); 116 | $this->expectExceptionMessage("General system error"); 117 | 118 | $res = new SearchRetrieveResponse(' 119 | 1.1 120 | 0 121 | 122 | 123 | info:srw/diagnostic/1/1 124 | 125 | 126 | '); 127 | } 128 | 129 | public function testErrorWithCustomMessage() 130 | { 131 | $this->expectException(\Scriptotek\Sru\Exceptions\SruErrorException::class); 132 | $this->expectExceptionMessage("Too many boolean operators, the maximum is 10. Please try a less complex query. (10)"); 133 | 134 | $res = new SearchRetrieveResponse(' 135 | 1.1 136 | 0 137 | 138 | 139 | info:srw/diagnostic/1/10 140 | Too many boolean operators, the maximum is 10. Please try a less complex query. 141 |
10
142 |
143 |
144 |
'); 145 | } 146 | 147 | // Should not throw error 148 | public function testDiagnosticsWithoutError() 149 | { 150 | $res = new SearchRetrieveResponse(' 151 | 1.1 152 | 0 153 | 154 | 155 | '); 156 | $this->assertCount(0, $res->records); 157 | } 158 | 159 | public function testCanBeInitializedWithoutAnyData() 160 | { 161 | $res = new SearchRetrieveResponse; 162 | $this->assertNull($res->version); 163 | } 164 | 165 | } 166 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | 10 | marcxchange 11 | xml 12 | {{position}} 13 | {{data}} 14 | '; 15 | 16 | protected $mainTpl = ' 17 | 21 | 1.1 22 | {{numberOfRecords}} 23 | 24 | {{records}} 25 | 26 | 27 | searchRetrieve 28 | 1.1 29 | {{cql}} 30 | {{startRecord}} 31 | {{maxRecords}} 32 | marcxchange 33 | 34 | 35 | 2014-03-28T12:09:50Z 36 | 37 | '; 38 | 39 | /** 40 | * Get an item from an array using "dot" notation. 41 | * Source: http://laravel.com/api/source-function-array_get.html#226-251 42 | * 43 | * @param array $array 44 | * @param string $key 45 | * @param mixed $default 46 | * @return mixed 47 | */ 48 | private function array_get($array, $key, $default = null) 49 | { 50 | if (is_null($key)) { 51 | return $array; 52 | } 53 | 54 | if (isset($array[$key])) { 55 | return $array[$key]; 56 | } 57 | 58 | foreach (explode('.', $key) as $segment) { 59 | if (! is_array($array) or ! array_key_exists($segment, $array)) { 60 | return $default; 61 | } 62 | 63 | $array = $array[$segment]; 64 | } 65 | 66 | return $array; 67 | } 68 | 69 | /** 70 | * numberOfRecords : Total number of records in response 71 | */ 72 | public function makeDummyResponse($numberOfRecords = 10, $options = array()) 73 | { 74 | // Request: CQL 75 | $cql = $this->array_get($options, 'cql', 'dummy'); 76 | 77 | // Request: First record to fetch 78 | $startRecord = $this->array_get($options, 'startRecord', 1); 79 | 80 | // Request: Max number of records to return 81 | $maxRecords = $this->array_get($options, 'maxRecords', 10); 82 | 83 | $endRecord = $startRecord + min($maxRecords - 1, $numberOfRecords - $startRecord); 84 | 85 | $recordTpl = $this->recordTpl; 86 | $records = implode('', array_map(function ($n) use ($recordTpl) { 87 | return str_replace( 88 | array('{{position}}', '{{data}}'), 89 | array($n, 'RecordData #' . $n), 90 | $recordTpl 91 | ); 92 | }, range($startRecord, $endRecord))); 93 | 94 | return str_replace( 95 | array('{{records}}', '{{cql}}', '{{startRecord}}', '{{maxRecords}}', '{{numberOfRecords}}'), 96 | array($records, $cql, $startRecord, $maxRecords, $numberOfRecords), 97 | $this->mainTpl 98 | ); 99 | } 100 | 101 | /** 102 | * Returns a series of responses (no matter what request) 103 | */ 104 | protected function httpMockWithResponses($bodies) 105 | { 106 | if (!is_array($bodies)) { 107 | $bodies = [$bodies]; 108 | } 109 | $http = new MockHttp(); 110 | foreach ($bodies as $body) { 111 | $http->addResponse( 112 | (new HttpResponse()) 113 | ->withBody(stream_for($body)) 114 | ); 115 | } 116 | 117 | return $http; 118 | } 119 | } 120 | --------------------------------------------------------------------------------