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
+
+}