diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..677e36e --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24d7538 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.travis.yml export-ignore +.scrutinizer.yml export-ignore +/CONTRIBUTING.md export-ignore +/tests/ export-ignore +/phpunit.xml.dist export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..347d2f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# temporary files +.DS_Store +Thumbs.db +.php_cs.cache + +# PHP files +vendor/ +phpunit.xml +composer.phar +composer.lock +*.rej +build/ diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..027fc8f --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,6 @@ +filter: + paths: [src/*] +checks: + php: + code_rating: true + duplication: true diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..3a45a1f --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: php +php: + - 7.0 + - 7.1 + +matrix: + fast_finish: true + +install: + - composer install + +script: + - mkdir -p build/logs + - composer test + +after_script: + - composer require satooshi/php-coveralls + - php vendor/bin/coveralls diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..cb564da --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,163 @@ +GNU Lesser General Public License +================================= + +_Version 3, 29 June 2007_ +_Copyright © 2007 Free Software Foundation, Inc. <>_ + +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + +This version of the GNU Lesser General Public License incorporates +the terms and conditions of version 3 of the GNU General Public +License, supplemented by the additional permissions listed below. + +### 0. Additional Definitions + +As used herein, “this License” refers to version 3 of the GNU Lesser +General Public License, and the “GNU GPL” refers to version 3 of the GNU +General Public License. + +“The Library” refers to a covered work governed by this License, +other than an Application or a Combined Work as defined below. + +An “Application” is any work that makes use of an interface provided +by the Library, but which is not otherwise based on the Library. +Defining a subclass of a class defined by the Library is deemed a mode +of using an interface provided by the Library. + +A “Combined Work” is a work produced by combining or linking an +Application with the Library. The particular version of the Library +with which the Combined Work was made is also called the “Linked +Version”. + +The “Minimal Corresponding Source” for a Combined Work means the +Corresponding Source for the Combined Work, excluding any source code +for portions of the Combined Work that, considered in isolation, are +based on the Application, and not on the Linked Version. + +The “Corresponding Application Code” for a Combined Work means the +object code and/or source code for the Application, including any data +and utility programs needed for reproducing the Combined Work from the +Application, but excluding the System Libraries of the Combined Work. + +### 1. Exception to Section 3 of the GNU GPL + +You may convey a covered work under sections 3 and 4 of this License +without being bound by section 3 of the GNU GPL. + +### 2. Conveying Modified Versions + +If you modify a copy of the Library, and, in your modifications, a +facility refers to a function or data to be supplied by an Application +that uses the facility (other than as an argument passed when the +facility is invoked), then you may convey a copy of the modified +version: + +* **a)** under this License, provided that you make a good faith effort to +ensure that, in the event an Application does not supply the +function or data, the facility still operates, and performs +whatever part of its purpose remains meaningful, or + +* **b)** under the GNU GPL, with none of the additional permissions of +this License applicable to that copy. + +### 3. Object Code Incorporating Material from Library Header Files + +The object code form of an Application may incorporate material from +a header file that is part of the Library. You may convey such object +code under terms of your choice, provided that, if the incorporated +material is not limited to numerical parameters, data structure +layouts and accessors, or small macros, inline functions and templates +(ten or fewer lines in length), you do both of the following: + +* **a)** Give prominent notice with each copy of the object code that the +Library is used in it and that the Library and its use are +covered by this License. +* **b)** Accompany the object code with a copy of the GNU GPL and this license +document. + +### 4. Combined Works + +You may convey a Combined Work under terms of your choice that, +taken together, effectively do not restrict modification of the +portions of the Library contained in the Combined Work and reverse +engineering for debugging such modifications, if you also do each of +the following: + +* **a)** Give prominent notice with each copy of the Combined Work that +the Library is used in it and that the Library and its use are +covered by this License. + +* **b)** Accompany the Combined Work with a copy of the GNU GPL and this license +document. + +* **c)** For a Combined Work that displays copyright notices during +execution, include the copyright notice for the Library among +these notices, as well as a reference directing the user to the +copies of the GNU GPL and this license document. + +* **d)** Do one of the following: + - **0)** Convey the Minimal Corresponding Source under the terms of this +License, and the Corresponding Application Code in a form +suitable for, and under terms that permit, the user to +recombine or relink the Application with a modified version of +the Linked Version to produce a modified Combined Work, in the +manner specified by section 6 of the GNU GPL for conveying +Corresponding Source. + - **1)** Use a suitable shared library mechanism for linking with the +Library. A suitable mechanism is one that **(a)** uses at run time +a copy of the Library already present on the user's computer +system, and **(b)** will operate properly with a modified version +of the Library that is interface-compatible with the Linked +Version. + +* **e)** Provide Installation Information, but only if you would otherwise +be required to provide such information under section 6 of the +GNU GPL, and only to the extent that such information is +necessary to install and execute a modified version of the +Combined Work produced by recombining or relinking the +Application with a modified version of the Linked Version. (If +you use option **4d0**, the Installation Information must accompany +the Minimal Corresponding Source and Corresponding Application +Code. If you use option **4d1**, you must provide the Installation +Information in the manner specified by section 6 of the GNU GPL +for conveying Corresponding Source.) + +### 5. Combined Libraries + +You may place library facilities that are a work based on the +Library side by side in a single library together with other library +facilities that are not Applications and are not covered by this +License, and convey such a combined library under terms of your +choice, if you do both of the following: + +* **a)** Accompany the combined library with a copy of the same work based +on the Library, uncombined with any other library facilities, +conveyed under the terms of this License. +* **b)** Give prominent notice with the combined library that part of it +is a work based on the Library, and explaining where to find the +accompanying uncombined form of the same work. + +### 6. Revised Versions of the GNU Lesser General Public License + +The Free Software Foundation may publish revised and/or new versions +of the GNU Lesser General Public License from time to time. Such new +versions will be similar in spirit to the present version, but may +differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the +Library as you received it specifies that a certain numbered version +of the GNU Lesser General Public License “or any later version” +applies to it, you have the option of following the terms and +conditions either of that published version or of any later version +published by the Free Software Foundation. If the Library as you +received it does not specify a version number of the GNU Lesser +General Public License, you may choose any version of the GNU Lesser +General Public License ever published by the Free Software Foundation. + +If the Library as you received it specifies that a proxy can decide +whether future versions of the GNU Lesser General Public License shall +apply, that proxy's public statement of acceptance of any version is +permanent authorization for you to choose that version for the +Library. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4487ab5 --- /dev/null +++ b/Makefile @@ -0,0 +1,6 @@ +info: + @echo "Usage: make test|install" + +# pass any target to composer +$(MAKECMDGOALS): + composer $(MAKECMDGOALS) diff --git a/README.md b/README.md new file mode 100644 index 0000000..01f6ff5 --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# jskos-http - JSKOS API implementation + +[![Latest Version](https://img.shields.io/packagist/v/gbv/jskos-http.svg?style=flat-square)](https://packagist.org/packages/gbv/jskos) +[![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE) +[![Build Status](https://img.shields.io/travis/gbv/jskos-http.svg?style=flat-square)](https://travis-ci.org/gbv/jskos-http) +[![Coverage Status](https://img.shields.io/coveralls/gbv/jskos-http/master.svg?style=flat-square)](https://coveralls.io/r/gbv/jskos-http) +[![Quality Score](https://img.shields.io/scrutinizer/g/gbv/jskos-http.svg?style=flat-square)](https://scrutinizer-ci.com/g/gbv/jskos-http) +[![Total Downloads](https://img.shields.io/packagist/dt/gbv/jskos-http.svg?style=flat-square)](https://packagist.org/packages/gbv/jskos) + + +# Requirements + +Requires PHP 7.0 or PHP 7.1 and package [jskos](https://packagist.org/packages/gbv/jskos). + +Bugs and feature request are [tracked on GitHub](https://github.com/gbv/jskos-http/issues). + +# Installation + +## With composer + +Install the latest version with + +~~~bash +composer require gbv/jskos-http +~~~ + +This will automatically create `composer.json` for your project (unless it already exists) and add jskos-http as dependency. Composer also generates `vendor/autoload.php` to get autoloading of all dependencies: +# Usage and examples + +The [jskos-php-examples repository](https://github.com/gbv/jskos-php-examples) +contains several examples, including wrappers of existing terminology services +(Wikidata, GND...) to JSKOS-API. + +The examples can be tried online at . + +# Author and License + +Jakob Voß + +JSKOS-HTTP is licensed under the LGPL license - see `LICENSE.md` for details. + +# See alse + +JSKOS is created as part of project coli-conc: . + +The current specification of JSKOS is available at . diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..79e2142 --- /dev/null +++ b/composer.json @@ -0,0 +1,40 @@ +{ + "name": "gbv/jskos-http", + "description": "JSKOS API implementation (server and client)", + "keywords": ["SKOS"], + "homepage": "http://gbv.github.io/jskos-http/", + "type": "library", + "license": "LGPL", + "author": [ + { + "name": "Jakob Voß", + "email": "jakob.voss@gbv.de" + } + ], + "require": { + "php": ">=7.0", + "gbv/jskos": "~0.2", + "symfony/cache": ">=3.3.6", + "symfony/yaml": ">=2.8.0", + "php-http/client-implementation": "^1.0", + "php-http/client-common": "^1.0", + "php-http/discovery": "^1.0" + }, + "suggest": { + }, + "require-dev": { + "phpunit/phpunit": "~6.1", + "php-http/guzzle6-adapter": ">=1.1.1", + "php-http/mock-client": "^1.0" + }, + "autoload": { + "psr-4": { + "JSKOS\\": "src/" + } + }, + "scripts": { + "test": [ + "vendor/bin/phpunit tests --coverage-clover build/logs/clover.xml" + ] + } +} diff --git a/examples/client.php b/examples/client.php new file mode 100644 index 0000000..bed771a --- /dev/null +++ b/examples/client.php @@ -0,0 +1,21 @@ +query($query); +print $jskos->json() . "\n"; diff --git a/src/Client.php b/src/Client.php new file mode 100644 index 0000000..0d558e4 --- /dev/null +++ b/src/Client.php @@ -0,0 +1,58 @@ +baseURL = $url; + $this->methods = $methods ?? [ + 'top', 'broader', 'narrower', 'descendants', 'ancestors' + ]; + $this->httpClient = $client ?: new HttpMethodsClient( + HttpClientDiscovery::find(), + MessageFactoryDiscovery::find() + ); + } + + public function query(array $query, string $method='') { + $query = array_intersect_key( + $query, + array_flip(['uri','id','notation','type','limit','offset','properties']) + ); + + $url = $this->baseURL; + + if ($method) { + if (in_array($this->methods, $method)) { + $url .= "/$method"; + } else { + return new Page(); + } + } + + if (count($query)) { + $url .= '?' . http_build_query($query); + } + + $response = $this->httpClient->get($url); + + # TOOD: catch error, broken JSON etc. + $json = $response->getBody()->getContents(); + $data = json_decode($json, true); + + # TODO: add total, offset, limit of page + return new Page($data ?? []); + } +} diff --git a/src/ConfiguredService.php b/src/ConfiguredService.php new file mode 100644 index 0000000..768916d --- /dev/null +++ b/src/ConfiguredService.php @@ -0,0 +1,40 @@ +config = Yaml::parse(file_get_contents($file)); + + if (isset($this->config['_uriSpace'])) { + $this->uriSpaceService = new URISpaceService($this->config['_uriSpace']); + } + } + + public function queryURISpace($query) + { + return $this->uriSpaceService ? $this->uriSpaceService->query($query) : null; + } +} diff --git a/src/Error.php b/src/Error.php new file mode 100644 index 0000000..53bb568 --- /dev/null +++ b/src/Error.php @@ -0,0 +1,39 @@ +code = $code; + $this->error = $error; + $this->message = $message; + $this->description = $description; + $this->uri = $uri; + } +} diff --git a/src/Response.php b/src/Response.php new file mode 100644 index 0000000..5535f68 --- /dev/null +++ b/src/Response.php @@ -0,0 +1,104 @@ +status = $status; + $this->headers = $headers; + $this->content = $content; + $this->emptyBody = false; + $this->callback = $callback; + } + + /** + * Send HTTP headers and content as string. + */ + public function send() + { + if ($this->callback) { + $this->headers['Content-Type'] = 'application/javascript; charset=utf-8'; + } else { + $this->headers['Content-Type'] = 'application/json'; + } + + if (!is_null($this->content)) { + + # TODO: catch exception + $body = $this->getBody(); + + $this->headers['Content-Length'] = strlen($body); + + if ($this->emptyBody) { + unset($body); + } + } + + $this->sendHeaders(); + + if (isset($body)) { + echo $body; + } + } + + /** + * Get the response body as string. + * + * @return string + */ + public function getBody() + { + if ($this->callback) { + return "/**/".$this->callback."(".$this->content.");"; + } else { + return (string)$this->content->json(); + } + } + + /** + * Sent HTTP headers and HTTP response code. + */ + protected function sendHeaders() + { + http_response_code($this->status); + foreach ($this->headers as $name => $value) { + header("$name: $value"); + } + } +} diff --git a/src/Server.php b/src/Server.php new file mode 100644 index 0000000..51bd8bb --- /dev/null +++ b/src/Server.php @@ -0,0 +1,339 @@ +run(); + * @endcode + */ +class Server implements \Psr\Log\LoggerAwareInterface +{ + /** + * @var string $API_VERSION JSKOS-API Version of this implementation + */ + public static $API_VERSION = '0.0.0'; + + /** + * @var Service $service + */ + protected $service; + + /** + * PRS-3 compliant LoggerInterface for logging. + * @var LoggerInterface $logger + */ + protected $logger; + + /** + * Create a new Server. + * @param Service $service + */ + public function __construct(Service $service = null) + { + $this->service = is_null($service) ? new Service() : $service; + $this->logger = new DefaultErrorLogger(); + } + + /** + * Sets the current Service to service. + * @param Service $service + */ + public function setService(Service $service) + { + $this->service = $service; + } + + /** + * Gets the current Service. + * @return Service + */ + public function getService() + { + return $this->service; + } + + /** + * Sets a logger for the server. + * + * The default logger logs errors via trigger_error. + * + * @param LoggerInterface $logger + * @return null + */ + public function setLogger(\Psr\Log\LoggerInterface $logger) + { + $this->logger = $logger; + } + + /** + * Returns the current logger. + * + * @return LoggerInterface + */ + public function getLogger() + { + return $this->logger; + } + + /** + * Receive request and send Response. + */ + public function run() + { + $this->response()->send(); + } + + /** + * Directly run a new server with a given Service. + * + * @code + * Server::runService($service); + * @endcode + * + * is equivalent to + * + * @code + * $server = new Server($service); + * $server->run(); + * @endcode + */ + public static function runService(Service $service) + { + $server = new Server($service); + $server->run(); + } + + /** + * Extract requested languages(s) from request. + */ + public function extractRequestLanguage($params) + { + $language = null; + + # get query modifier: language + if (isset($params['language'])) { + $language = $params['language']; + unset($params['language']); + # TODO: parse language + } elseif (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) { + # parse accept-language-header + preg_match_all( + '/([a-z]+(?:-[a-z]+)?)\s*(?:;\s*q\s*=\s*(1|0?\.[0-9]+))?/i', + $_SERVER['HTTP_ACCEPT_LANGUAGE'], + $match); + if (count($match[1])) { + foreach ($match[1] as $i => $l) { + if (isset($match[2][$i]) && $match[2][$i] != '') { + $langs[strtolower($l)] = (float) $match[2][$i]; + } else { + $langs[strtolower($l)] = 1; + } + } + arsort($langs, SORT_NUMERIC); + reset($langs); + $language = key($langs); # most wanted language + } + } + + return $language; + } + + /** + * Receive request and create a Response. + * + * This is the core method implementing basic parts of JSKOS API. + * The method handles HTTP request method, request headers and + * [query modifiers](https://gbv.github.io/jskos-api/#query-modifiers), + * passes valid GET and HEAD requests to the served Service and wraps + * the result as Response. + * + * @return Response + */ + public function response() + { + $method = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : 'GET'; + $params = $_GET; + + if ($method == 'OPTIONS') { + $this->logger->info("Received OPTIONS request"); + return $this->optionsResponse(); + } + + # get query modifier: callback + if (isset($params['callback'])) { + $callback = $params['callback']; + if (!preg_match('/^[$A-Z_][0-9A-Z_$.]*$/i', $callback)) { + unset($callback); + } + unset($params['callback']); + } + + $language = $this->extractRequestLanguage($params); + + # TODO: extract more query modifiers + + # TODO: header: Allow/Authentication + + $error = null; + + # conflicting parameters + if (isset($params['uri']) and isset($params['search'])) { + $error = new Error('422', 'request_error', 'Conflicting request parameters uri & search'); + } + + if (!$error and ($method == 'GET' or $method == 'HEAD')) { + $this->logger->info("Received $method request", $params); + + $answer = $this->queryService($params); + + if ($answer instanceof Page) { + + // TODO: if unique + + $response = $this->basicResponse(200, $answer); + + // TODO: Add Link header with next/last/first + + $response->headers['X-Total-Count'] = $answer->totalCount; + + if ($method == 'HEAD') { + $response->emptyBody = true; + } + } elseif ($answer instanceof Error) { + $error = $answer; + } + } elseif (!$error) { + $error = new Error(405, '???', 'Method not allowed'); + } + + if (isset($error)) { + $this->logger->warning($error->message, ['error' => $error]); + $response = $this->basicResponse($error->code, $error); + } + + if (isset($callback)) { + $response->callback = $callback; + } + + return $response; + } + + /** + * Delegate request to service. + * + * Makes sure that exceptions are catched. + * + * @return Page|Error + */ + protected function queryService($params) + { + $supportedTypes = $this->service->getSupportedTypes(); + $supportedParameters = $this->service->getSupportedParameters(); + $possibleParameters = array_merge($supportedParameters, \JSKOS\QueryModifiers); + + # filter out queries for unsupported types + if (count($supportedTypes) and isset($params['type'])) { + if (!in_array($params['type'], $supportedTypes)) { + return new Page([]); + } + } + + # remove unknown parameters + foreach (array_keys($params) as $name) { + if (!in_array($name, $possibleParameters)) { + $this->logger->notice('Unsupported query parameter {name}', [ + 'name' => $name, 'value' => $params[$name] ]); + unset($params[$name]); + } + } + + # make sure all supported query parameters exist + foreach ($supportedParameters as $name) { + if (!isset($params[$name])) { + $params[$name] = null; + } + } + + try { + $response = $this->service->query($params); + } catch (\Exception $e) { + $this->logger->error('Service Exception', ['exception' => $e]); + return new Error(500, '???', 'Internal server error'); + } + + if (is_null($response)) { + return new Page([]); + } elseif ($response instanceof Item) { + return new Page([$response]); + } elseif ($response instanceof Page or $response instanceof Error) { + return $response; + } else { + $this->logger->error('Service response has wrong type', ['response' => $response]); + return new Error(500, '???', 'Internal server error'); + } + + return $response; + } + + /** + * Create a Response object with standard JSKOS-API headers. + * @param integer $code HTTP Status code + * @return Response + */ + protected function basicResponse($code=200, $content=null) + { + return new Response( + $code, + [ + 'Access-Control-Allow-Origin' => '*', + 'X-JSKOS-API-Version' => self::$API_VERSION, + 'Link-Template' => '<'.$this->service->uriTemplate().'>; rel="search"', + ], + $content + ); + } + + /** + * Respond to a HTTP OPTIONS request. + * @return Response + */ + protected function optionsResponse() + { + $response = $this->basicResponse(); + + $response->headers['Access-Control-Allow-Methods'] = 'GET, HEAD, OPTIONS'; + if (isset($_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD']) && + $_SERVER['HTTP_ACCESS_CONTROL_REQUEST_METHOD'] == 'GET') { + $response->headers['Access-Control-Allow-Origin'] = '*'; + $response->headers['Acess-Control-Expose-Headers'] = 'Link, X-Total-Count'; + } + + return $response; + } +} diff --git a/src/Service.php b/src/Service.php new file mode 100644 index 0000000..755ff45 --- /dev/null +++ b/src/Service.php @@ -0,0 +1,152 @@ +run(); + * @endcode + * + * Or create a subclass that overrides the query method and possibly the + * supportedParameters member variable: + * + * @code + * class MyService extends \JSKOS\Service { + * + * protected $supportedParameters = [...]; + * + * public function query($request) { + * ... + * } + * + * } + * @endcode + * + * Each %Service can be configured to support specific query parameters, in + * addition to the mandatory parameter `uri`. The list of supported parameters + * can be returned as URI Template. + * + * @code + * $service->supportParameter('notation'); + * $service->uriTemplate(); # '{?uri}{?notation}' + * @endcode + * + * @see Server + */ +class Service +{ + private $queryFunction; /**< callable */ + + /** + * List of supported query parameters. + * @var array + */ + protected $supportedParameters = []; + + /** + * List of available types, given by their URIs. + * + * If left empty then all types are possible. The query parameter 'type' is + * added to the list of supported query parameter otherwise, so requests can + * be checked before perfoming a query and results can be checked for expected + * types. + * + * @var array + */ + protected $supportedTypes = []; + + /** + * Create a new service. + */ + public function __construct($queryFunction=null) + { + if (!isset($queryFunction)) { + $queryFunction = function () { + return new Page(); + }; + } elseif (!is_callable($queryFunction)) { + throw new \InvalidArgumentException('queryFunction must be callable'); + } + $this->queryFunction = $queryFunction; + + $this->supportParameter('uri'); + if (count($this->supportedTypes) and !in_array('type', $this->supportedParameters)) { + $this->supportParameter('type'); + } + } + + /** + * Perform a query. + * + * @return Page|Error + */ + public function query(array $request, string $method='') + { + $method = $this->queryFunction; + # TODO: check whether result is actually a Page or Error + return $method($request); + } + + /** + * Enable support of a query parameter. + * @param string $name + */ + public function supportParameter($name) + { + if (in_array($name, QueryModifiers)) { + throw new \DomainException("parameter $name not allowed"); + } + $this->supportedParameters[$name] = $name; + asort($this->supportedParameters); + } + + /** + * Get a list of supported query parameters. + * @return array + */ + public function getSupportedParameters() + { + return $this->supportedParameters; + } + + /** + * Get a list of supported type URIs. + * @return array + */ + public function getSupportedTypes() + { + return $this->supportedTypes; + } + + /** + * Get a list of query parameters as URI template. + * + * @return string + */ + public function uriTemplate($template='') + { + foreach ($this->supportedParameters as $name) { + $template .= "{?$name}"; + } + return $template; + } +} diff --git a/src/URISpaceService.php b/src/URISpaceService.php new file mode 100644 index 0000000..6ce66a7 --- /dev/null +++ b/src/URISpaceService.php @@ -0,0 +1,124 @@ + $typeConfig) { + if (!$typeConfig['uriSpace']) { + throw new \Exception('Missing field uriSpace'); + } elseif (!DataType::isURI($typeConfig['uriSpace'])) { + throw new \Exception('uriSpace must be an URI'); + } + if (!class_exists("JSKOS\\$type")) { + throw new \Exception("Class JSKOS\\$type not found!"); + } + if (!isset($typeConfig['notationPattern'])) { + $typeConfig['notationPattern'] = '/.*/'; + } + if (isset($typeConfig['notationNormalizer'])) { + // THIS CAN BE A SECURITY ISSUE! + $normalizer = $typeConfig['notationNormalizer']; + if (!function_exists($normalizer)) { + throw new \Exception("Function $normalizer not found!"); + } + } else { + $typeConfig['notationNormalizer'] = null; + } + $this->config[$type] = [ + 'uriSpace' => $typeConfig['uriSpace'], + 'notationPattern' => $typeConfig['notationPattern'], + 'notationNormalizer' => $typeConfig['notationNormalizer'], + ]; + } + } + + public function query(array $query, string $method='') + { + $class = null; + + if (isset($query['uri']) and $query['uri'] !== "") { + foreach ($this->config as $type => $config) { + // request URI matches uriSpace + if (strpos($query['uri'], $config['uriSpace']) === 0) { + $uri = $query['uri']; + $notation = substr($uri, strlen($config['uriSpace'])); + + if (!preg_match($config['notationPattern'], $notation)) { + return; + } + + if ($config['notationNormalizer']) { + $notation = $config['notationNormalizer']($notation); + } + + if (!$notation and $notation !== '0') { + unset($notation); + } + + $class = "JSKOS\\$type"; + + break; + } + } + if (!isset($uri)) { + return; + } + } + + if (isset($query['notation']) and $query['notation'] !== "") { + if (isset($notation)) { + if ($query['notation'] != $notation) { + // requested notation and uri do not match + return; + } + } else { + foreach ($this->config as $type => $config) { + if (preg_match($config['notationPattern'], $query['notation'])) { + $notation = $query['notation']; + if ($config['notationNormalizer']) { + $notation = $config['notationNormalizer']($notation); + } + + $class = "JSKOS\\$type"; + + if (!isset($uri)) { + $uri = $config['uriSpace'] . $notation; + } + break; + } + } + if (!isset($notation)) { + return; + } + } + } + + if ($class and isset($uri)) { + if (isset($notation)) { + return new $class(['uri' => $uri, 'notation' => [$notation]]); + } else { + return new $class(['uri' => $uri]); + } + } + } +} diff --git a/tests/ClientTest.php b/tests/ClientTest.php new file mode 100644 index 0000000..8d42b09 --- /dev/null +++ b/tests/ClientTest.php @@ -0,0 +1,29 @@ +query([]); + $this->assertEquals(new Page(), $page); + } +} diff --git a/tests/ConfiguredServiceTest.php b/tests/ConfiguredServiceTest.php new file mode 100644 index 0000000..3e010cb --- /dev/null +++ b/tests/ConfiguredServiceTest.php @@ -0,0 +1,33 @@ +config; + } +} + +/** + * @covers \JSKOS\ConfiguredService + */ +class ConfiguredServiceTest extends \PHPUnit\Framework\TestCase +{ + + public function testService() + { + $service = new SampleService(); + + $this->assertSame( $service->getConfig()["foo"], ['bar' => 'doz']); + + $concept = $service->queryURISpace(['notation' => '123']); + $this->assertEquals( $concept, new Concept([ + 'uri' => 'http://example.org/concept/123', + 'notation' => ['123'] + ])); + } +} + diff --git a/tests/SampleService.yaml b/tests/SampleService.yaml new file mode 100644 index 0000000..16a4d62 --- /dev/null +++ b/tests/SampleService.yaml @@ -0,0 +1,9 @@ +--- +_uriSpace: + Concept: + uriSpace: http://example.org/concept/ + notationPattern: /^[0-9]+$/ + +foo: + bar: doz +... diff --git a/tests/ServerTest.php b/tests/ServerTest.php new file mode 100644 index 0000000..9453b56 --- /dev/null +++ b/tests/ServerTest.php @@ -0,0 +1,209 @@ +log[]=[$level, $message, $context]; + } +} + +class MyConceptService extends \JSKOS\Service +{ + protected $supportedTypes = ['http://www.w3.org/2004/02/skos/core#Concept']; + public function query(array $request, string $method='') + { + if ($request['uri']) { + return new Concept(['uri'=>$request['uri']]); + } else { + return; + } + } +} + +class MySearchService extends MyConceptService +{ + protected $supportedParameters = ['search']; + public function query(array $request, string $method='') + { + if ($request['search']) { + $a = new Concept(['uri'=>'x:a']); + $b = new Concept(['uri'=>'x:b']); + return new Page([$a,$b],0,1,42); + } + } + } + +/** + * @covers JSKOS\Server + */ +class ServerTest extends \PHPUnit\Framework\TestCase +{ + private $server; + private $response; + private $logger; + + private function newServer($service=null) + { + $this->server = new Server($service); + } + + private function newLogger() + { + $this->logger = new MockLogger(); + $this->server->setLogger($this->logger); + } + + private function condenseLog() + { + return array_map(function ($m) { + return "$m[0]: $m[1]"; + }, $this->logger->log); + } + + private function getRequest($headers=[], $params=[]) + { + $_SERVER['REQUEST_METHOD'] = 'GET'; + foreach ($headers as $name => $value) { + $_SERVER['HTTP_'.$name] = $value; + } + $_GET = $params; + + $this->response = $this->server->response(); + } + + private function assertResponse($status, $headers=[], $body=[]) + { + $this->assertEquals($status, $this->response->status); + + $this->assertArraySubset($headers, $this->response->headers); + + if (is_array($body)) { + $json = json_decode($this->response->getBody(),true); + $this->assertArraySubset( $body, $json ); + } else { + $this->assertEquals($body, $this->response->getBody()); + } + } + + + public function testSomeRequest() + { + $this->newServer(); + $this->getRequest(); + + $headers = [ + 'X-JSKOS-API-Version' => '0.0.0', + 'X-Total-Count' => 0, + 'Link-Template' => '<{?uri}>; rel="search"', + # TODO: Link: rel="collection" to concept scheme or registry + ]; + $this->assertResponse(200, $headers, '[]'); + + $this->getRequest([],['callback' => 'abc']); + $this->assertResponse(200, $headers, '/**/abc([]);'); + } + + public function testLogging() + { + $logger = new MockLogger(); + + $this->newServer(); + $this->server->setLogger($logger); + $this->assertSame($logger, $this->server->getLogger()); + + $this->getRequest(); + $this->assertEquals([ + ["info","Received GET request",[]] + ],$logger->log); + } + + /** + * @expectedException PHPUnit\Framework\Exception + */ + public function testDefaultLogger() + { + $service = new Service(function($query) { throw new \Exception("!"); }); + $this->newServer($service); + $this->getRequest(); + } + + public function testService() + { + $this->newServer(); + $this->assertInstanceOf('\JSKOS\Service', $this->server->getService()); + + $service = new Service(); + $this->server->setService($service); + $this->assertSame( $service, $this->server->getService() ); + } + + public function testServiceException() + { + $service = new Service(function($query) { throw new \Exception("!"); }); + $this->newServer($service); + $this->newLogger(); + + $this->getRequest(); + $this->assertEquals([ + "info: Received GET request", + "error: Service Exception", + "warning: Internal server error" + ], $this->condenseLog() + ); + } + + public function testServiceWrongResponse() + { + $service = new Service(function($query) { return 42; }); + $this->newServer($service); + $this->newLogger(); + $this->getRequest(); + $this->assertEquals([ + "info: Received GET request", + "error: Service response has wrong type", + "warning: Internal server error" + ], $this->condenseLog() + ); + $this->assertSame(42, $this->logger->log[1][2]['response']); + } + + public function testConceptService() + { + $this->newServer(new MyConceptService()); + + $this->getRequest(); + $this->assertEquals(0, $this->response->headers['X-Total-Count']); + + $this->newLogger(); + $this->getRequest([],['foo' => 'bar', 'uri' => 'http://example.org/', 'page' => 1]); + $this->assertEquals([ + "info: Received GET request", + "notice: Unsupported query parameter {name}" + ], $this->condenseLog() + ); + $this->assertEquals(1, $this->response->headers['X-Total-Count']); + + $this->getRequest([],['uri' => 'http://example.org/', 'type' => 'x:unknown']); + $this->assertEquals(0, $this->response->headers['X-Total-Count']); + + $this->getRequest([],['uri' => 'http://example.org/', + 'type' => 'http://www.w3.org/2004/02/skos/core#Concept']); + $this->assertEquals(1, $this->response->headers['X-Total-Count']); + } + + public function testSearchService() + { + $this->newServer(new MySearchService()); + + $this->getRequest([],['search'=>'foo']); + $this->assertEquals(42, $this->response->headers['X-Total-Count']); + $this->assertResponse( 200, [], [ ['uri'=>'x:a'], ['uri'=>'x:b'] ] ); + + // conflicting parameters + $this->getRequest([],['search'=>'foo','uri'=>'x:b']); + $this->assertResponse( 422, [], [ 'code' => 422 ] ); + } +} diff --git a/tests/ServiceTest.php b/tests/ServiceTest.php new file mode 100644 index 0000000..a047e08 --- /dev/null +++ b/tests/ServiceTest.php @@ -0,0 +1,78 @@ +$request["notation"]]); + } +} + +class MyOtherService extends \JSKOS\Service +{ + protected $supportedParameters = []; + protected $supportedTypes = ['http://www.w3.org/2004/02/skos/core#Concept']; +} + +/** + * @covers \JSKOS\Service + */ +class ServiceTest extends \PHPUnit\Framework\TestCase +{ + public function testQueryFunction() + { + $page = new Page(); + $method = function ($q) use ($page) { + return $page; + }; + $service = new Service($method); + $this->assertSame($page, $service->query([])); + } + + public function testDefaultQueryFunction() + { + $service = new Service(); + $this->assertInstanceOf('\JSKOS\Page', $service->query([])); + } + + public function testInvalidQueryFunction() + { + $this->expectException('InvalidArgumentException'); + $service = new Service(42); + } + + public function testSupportParameter() + { + $service = new Service(); + $this->assertEquals('{?uri}', $service->uriTemplate()); + + $service->supportParameter('notation'); + $this->assertEquals('{?notation}{?uri}', $service->uriTemplate()); + + $this->assertEquals(['notation'=>'notation','uri'=>'uri'], $service->getSupportedParameters()); + } + + public function testInvalidSupportParameter() + { + $this->expectException('DomainException'); + $service = new Service(); + $service->supportParameter('callback'); + } + + public function testSupportType() + { + $service = new MyOtherService(); + $this->assertEquals('{?type}{?uri}', $service->uriTemplate()); + } + + public function testInheritance() + { + $service = new MyService(); + $this->assertEquals('{?notation}{?uri}', $service->uriTemplate()); + $result = $service->query(['notation'=>'abc']); + $this->assertEquals(new Concept(['notation'=>'abc']), $result); + } +} diff --git a/tests/URISpaceServiceTest.php b/tests/URISpaceServiceTest.php new file mode 100644 index 0000000..5f0001b --- /dev/null +++ b/tests/URISpaceServiceTest.php @@ -0,0 +1,97 @@ + [ 'uriSpace' => 'http://example.org/' ] ]; + if ($notationPattern) { + $config['Concept']['notationPattern'] = $notationPattern; + } + $service = new URISpaceService($config); + + $this->assertNull($service->query([])); + $this->assertNull($service->query(['uri' => ''])); + $this->assertNull($service->query(['uri' => 'http://example.com'])); + $this->assertNull($service->query(['uri' => 'http://example.org'])); + + $concept = $service->query(['uri' => 'http://example.org/']); + $this->assertInstanceOf('JSKOS\Concept', $concept); + $this->assertSame('http://example.org/', $concept->uri); + $this->assertNull($concept->notation); + + $concept = $service->query(['uri' => 'http://example.org/foo']); + $this->assertInstanceOf('JSKOS\Concept', $concept); + $this->assertSame('http://example.org/foo', $concept->uri); + $this->assertEquals(new Listing(['foo']), $concept->notation); + + $this->assertNull($service->query([ + 'uri' => 'http://example.org/foo', + 'notation' => 'bar' + ])); + } + } + + public function testNotation() { + $service = new URISpaceService([ + 'Concept' => [ + 'uriSpace' => 'http://example.org/', + 'notationPattern' => '/[0-9]+/', + ] + ]); + + $this->assertNull($service->query(['uri' => 'http://example.org/'])); + $this->assertNull($service->query(['uri' => 'http://example.org/foo'])); + + $concept = $service->query(['uri' => 'http://example.org/123']); + $this->assertInstanceOf('JSKOS\Concept', $concept); + $this->assertSame('http://example.org/123', $concept->uri); + $this->assertEquals(new Listing(['123']), $concept->notation); + + // ignore empty notation + $this->assertNotNull($service->query([ + 'uri' => 'http://example.org/123', + 'notation' => '' + ])); + + // URI and notation don't match + $this->assertNull($service->query([ + 'uri' => 'http://example.org/123', + 'notation' => 'foo' + ])); + + $this->assertNull($service->query(['notation' => 'foo'])); + + $concept = $service->query(['notation' => '123']); + $this->assertInstanceOf('JSKOS\Concept', $concept); + $this->assertSame('http://example.org/123', $concept->uri); + $this->assertEquals(new Listing(['123']), $concept->notation); + } + + public function testNotationNormalizer() { + $service = new URISpaceService([ + 'Concept' => [ + 'uriSpace' => 'http://example.org/', + 'notationPattern' => '/[QP][0-9]+/i', + 'notationNormalizer' => 'strtoupper', + ] + ]); + $concept = $service->query(['notation' => 'q42']); + $this->assertSame('http://example.org/Q42', $concept->uri); + $this->assertEquals(new Listing(['Q42']), $concept->notation); + + $concept = $service->query(['uri' => 'http://example.org/q42']); + $this->assertSame('http://example.org/q42', $concept->uri); + $this->assertEquals(new Listing(['Q42']), $concept->notation); + } + + # TODO: test multiple types but Concept + +}