diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..582cc36 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +* text=auto + +*.php text eol=lf \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4997968 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/vendor +/node_modules +composer.lock +.phpunit.result.cache +.php_cs.cache +npm-debug.log +Thumbs.db +.DS_Store +.log +/.idea +/.vscode +/.vs diff --git a/.php_cs.dist b/.php_cs.dist new file mode 100644 index 0000000..382991f --- /dev/null +++ b/.php_cs.dist @@ -0,0 +1,125 @@ +in(__DIR__) + ->exclude('docs'); + +return Config::create()->setRules([ + '@PSR2' => true, + 'align_multiline_comment' => true, + 'array_syntax' => ['syntax' => 'short'], + 'backtick_to_shell_exec' => true, + 'binary_operator_spaces' => true, + 'blank_line_after_namespace' => true, + 'blank_line_after_opening_tag' => true, + 'blank_line_before_statement' => [ + 'statements' => [ + 'break', 'case', 'continue', 'declare', 'default', 'do', 'for', + 'if', 'foreach', 'return', 'switch', 'try', 'while', + ], + ], + 'braces' => [ + 'allow_single_line_closure' => false, + 'position_after_control_structures' => 'same', + 'position_after_functions_and_oop_constructs' => 'next', + ], + 'cast_spaces' => ['space' => 'single'], + 'class_attributes_separation' => [ + 'elements' => ['const', 'property', 'method'], + ], + 'combine_consecutive_issets' => true, + 'combine_consecutive_unsets' => true, + 'compact_nullable_typehint' => true, + 'concat_space' => ['spacing' => 'one'], + 'fully_qualified_strict_types' => true, + 'function_typehint_space' => true, + 'increment_style' => ['style' => 'pre'], + 'linebreak_after_opening_tag' => true, + 'list_syntax' => ['syntax' => 'short'], + 'lowercase_cast' => true, + 'lowercase_static_reference' => true, + 'magic_constant_casing' => true, + 'magic_method_casing' => true, + 'multiline_comment_opening_closing' => true, + 'multiline_whitespace_before_semicolons' => [ + 'strategy' => 'no_multi_line', + ], + 'native_function_casing' => true, + 'native_function_type_declaration_casing' => true, + 'new_with_braces' => true, + 'no_alternative_syntax' => true, + 'no_blank_lines_after_class_opening' => true, + 'no_blank_lines_after_phpdoc' => true, + 'no_empty_statement' => true, + 'no_extra_blank_lines' => true, + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_mixed_echo_print' => ['use' => 'echo'], + 'no_null_property_initialization' => true, + 'no_short_bool_cast' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_spaces_around_offset' => true, + 'no_superfluous_phpdoc_tags' => false, + 'no_superfluous_elseif' => true, + 'no_trailing_comma_in_list_call' => true, + 'no_trailing_comma_in_singleline_array' => true, + 'no_unneeded_control_parentheses' => true, + 'no_unneeded_curly_braces' => true, + 'no_unset_cast' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'no_whitespace_before_comma_in_array' => true, + 'no_whitespace_in_blank_line' => true, + 'normalize_index_brace' => true, + 'nullable_type_declaration_for_default_null_value' => true, + 'object_operator_without_whitespace' => true, + 'ordered_class_elements' => [ + 'order' => [ + 'use_trait', 'constant_public', 'constant_protected', + 'constant_private', 'property_public_static', 'property_protected_static', + 'property_private_static', 'property_public', 'property_protected', + 'property_private', 'method_public_static', 'method_protected_static', + 'method_private_static', 'construct', 'destruct', 'phpunit', + 'method_public', 'method_protected', 'method_private', 'magic', + ], + 'sortAlgorithm' => 'none', + ], + 'php_unit_fqcn_annotation' => true, + 'php_unit_method_casing' => ['case' => 'camel_case'], + 'php_unit_ordered_covers' => true, + 'phpdoc_add_missing_param_annotation' => ['only_untyped' => false], + 'phpdoc_align' => ['align' => 'left'], + 'phpdoc_inline_tag' => true, + 'phpdoc_line_span' => [ + 'const' => 'multi', + 'method' => 'multi', + 'property' => 'multi', + ], + 'phpdoc_no_access' => true, + 'phpdoc_no_empty_return' => true, + 'phpdoc_no_useless_inheritdoc' => true, + 'phpdoc_order' => true, + 'phpdoc_scalar' => true, + 'phpdoc_single_line_var_spacing' => true, + 'phpdoc_to_comment' => false, + 'phpdoc_trim' => true, + 'phpdoc_trim_consecutive_blank_line_separation' => true, + 'phpdoc_var_without_name' => true, + 'protected_to_private' => true, + 'return_assignment' => false, + 'return_type_declaration' => ['space_before' => 'one'], + 'semicolon_after_instruction' => true, + 'short_scalar_cast' => true, + 'simplified_null_return' => true, + 'single_blank_line_before_namespace' => true, + 'single_quote' => true, + 'single_line_comment_style' => true, + 'ternary_operator_spaces' => true, + 'ternary_to_null_coalescing' => true, + 'trim_array_spaces' => true, + 'unary_operator_spaces' => true, + 'whitespace_after_comma_in_array' => true, +])->setFinder($finder); \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..783b5b8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +0.0.1-beta + - Migrate code from Server package diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..20a4be0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Andrew DalPino + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fec905c --- /dev/null +++ b/README.md @@ -0,0 +1,182 @@ +# Rubix Client +The client SDK for Rubix ML Server. + +## Installation +Install Rubix Client SDK using [Composer](https://getcomposer.org/): + +```sh +$ composer require rubix/client +``` + +## Requirements +- [PHP](https://php.net/manual/en/install.php) 7.4 or above + +## Documentation +The latest documentation can be found below. + +### Table of Contents +- [Clients](#clients) + - [REST Client](#rest-client) +- [Client Middleware](#client-middleware) + - [Backoff and Retry](#backoff-and-retry) + - [Basic Authenticator](#basic-authenticator-client-side) + - [Compress Request Body](#compress-request-body) + - [Shared Token Authenticator](#shared-token-authenticator-client-side) + +--- +### Clients +Clients allow you to communicate directly with a model server using a friendly object-oriented interface inside your PHP applications. Under the hood, clients handle all the networking communication and content negotiation for you so you can write programs *as if* the model was directly accessible in your applications. + +Return the predictions from the model: +```php +public predict(Dataset $dataset) : array +``` + +```php +use Rubix\Client\RESTClient; + +$client = new RESTClient('127.0.0.1', 8080); + +// Import a dataset + +$predictions = $client->predict($dataset); +``` + +Calculate the joint probabilities of each sample in a dataset: +```php +public proba(Dataset $dataset) : array +``` + +Calculate the anomaly scores of each sample in a dataset: +```php +public score(Dataset $dataset) : array +``` + +### Async Clients +Clients that implement the Async Client interface have asynchronous versions of all the standard client methods. All asynchronous methods return a [Promises/A+](https://promisesaplus.com/) object that resolves to the return value of the response. Promises allow you to perform other work while the request is processing or to execute multiple requests in parallel. Calling the `wait()` method on the promise will block until the promise is resolved and return the value. + +```php +public predictAsync(Dataset $dataset) : Promise +``` + +```php +$promise = $client->predictAsync($dataset); + +// Do something else + +$predictions = $promise->wait(); +``` + +Return a promise for the probabilities predicted by the model: +```php +public probaAsync(Dataset $dataset) : Promise +``` + +Return a promise for the anomaly scores predicted by the model: +```php +public scoreAsync(Dataset $dataset) : Promise +``` + +### REST Client +The REST Client communicates with the [HTTP Server](#http-server) through the JSON REST API it exposes. + +Interfaces: [Client](#clients), [AsyncClient](#async-clients) + +#### Parameters +| # | Param | Default | Type | Description | +|---|---|---|---|---| +| 1 | host | '127.0.0.1' | string | The IP address or hostname of the server. | +| 2 | port | 8000 | int | The network port that the HTTP server is running on. | +| 3 | secure | false | bool | Should we use an encrypted HTTP channel (HTTPS)? | +| 4 | middlewares | | array | The stack of client middleware to run on each request/response. | +| 5 | timeout | | float | The number of seconds to wait before giving up on the request. | +| 6 | verify certificate | true | bool | Should we try to verify the server's TLS certificate? | + +**Example** + +```php +use Rubix\Client\RESTClient; +use Rubix\Client\HTTP\Middleware\BasicAuthenticator; +use Rubix\Client\HTTP\Middleware\CompressRequestBody; +use Rubix\Client\HTTP\Middleware\BackoffAndRetry; +use Rubix\Client\HTTP\Encoders\Gzip; + +$client = new RESTClient('127.0.0.1', 443, true, [ + new BasicAuthenticator('user', 'password'), + new CompressRequestBody(new Gzip(1)), + new BackoffAndRetry(), +], 0.0, true); +``` + +### Client Middleware +Similarly to Server middleware, client middlewares are functions that hook into the request/response cycle but from the client end. Some of the server middlewares have accompanying client middleware such as [Basic Authenticator](#basic-authenticator) and [Shared Token Authenticator](#shared-token-authenticator). + +### Backoff and Retry +The Backoff and Retry middleware handles Too Many Requests (429) and Service Unavailable (503) responses by retrying the request after waiting for a period of time to avoid overloading the server even further. An acceptable backoff period is gradually achieved by multiplicatively increasing the delay between retries. + +#### Parameters +| # | Param | Default | Type | Description | +|---|---|---|---|---| +| 1 | max retries | 3 | int | The maximum number of times to retry the request before giving up. | +| 2 | initial delay | 0.5 | float | The number of seconds to delay between retries before exponential backoff is applied. | + +**Example** + +```php +use Rubix\Client\HTTP\Middleware\BackoffAndRetry; + +$middleware = new BackoffAndRetry(5, 0.5); +``` + +### Basic Authenticator (Client Side) +Adds the necessary authorization headers to the request using the Basic scheme. + +#### Parameters +| # | Param | Default | Type | Description | +|---|---|---|---|---| +| 1 | username | | string | The user's name. | +| 2 | password | | string | The user's password. | + +**Example** + +```php +use Rubix\Client\HTTP\Middleware\BasicAuthenticator; + +$middleware = new BasicAuthenticator('morgan', 'secret'); +``` + +### Compress Request Body +Apply the Gzip compression algorithm to the request body. + +#### Parameters +| # | Param | Default | Type | Description | +|---|---|---|---|---| +| 1 | level | 1 | int | The compression level between 0 and 9 with 0 meaning no compression. | +| 2 | threshold | 65535 | int | The minimum size of the request body in bytes in order to be compressed. | + +**Example** + +```php +use Rubix\Client\HTTP\Middleware\CompressRequestBody; + +$middleware = new CompressRequestBody(5, 65535); +``` + +### Shared Token Authenticator (Client Side) +Adds the necessary authorization headers to the request using the Bearer scheme. + +#### Parameters +| # | Param | Default | Type | Description | +|---|---|---|---|---| +| 1 | token | | string | The shared token to authenticate the request. | + +**Example** + +```php +use Rubix\Client\HTTP\Middleware\SharedtokenAuthenticator; + +$middleware = new SharedTokenAuthenticator('secret'); +``` + +## License +The code is licensed [MIT](LICENSE) and the documentation is licensed [CC BY-NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1b3c663 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,2 @@ +# Reporting a Vulnerability +If you find a vulnerability in our software we ask that you alert us in a timely and private manner. You can report any security vulnerabilities to support@rubixml.com. In your message, please include a brief description of the vulnerability, the conditions, and steps necessary to reproduce the exploit. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..51e8c86 --- /dev/null +++ b/composer.json @@ -0,0 +1,89 @@ +{ + "name": "rubix/server", + "type": "library", + "description": "The client SDK for Rubix ML Server.", + "homepage": "https://github.com/RubixML/Client", + "license": "MIT", + "readme": "README.md", + "keywords": [ + "ai", "api", "cloud", "distributed", "graphql", "graph ql", "inference", "inference engine", + "inference server", "infrastructure", "json api", "machine learning", "microservice", "ml", + "ml infrastructure", "ml server", "model server", "model deployment", "php", "php ai", + "php machine learning", "php ml", "prediction", "rest api", "rest server", "rest client", + "rubix", "rubix ml", "rubixml", "server" + ], + "authors": [ + { + "name": "Andrew DalPino", + "role": "Project Lead", + "homepage": "https://github.com/andrewdalpino", + "email": "support@andrewdalpino.com" + }, + { + "name": "Contributors", + "homepage": "https://github.com/RubixML/Server/graphs/contributors" + } + ], + "require": { + "php": ">=7.4", + "guzzlehttp/guzzle": "^7.2", + "guzzlehttp/psr7": "^1.7", + "psr/http-message": "^1.0", + "rubix/ml": "^1.0", + "symfony/polyfill-php80": "^1.17" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "2.18.*", + "phpstan/phpstan": "0.12.*", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan-phpunit": "0.12.*", + "phpunit/phpunit": "8.5.*" + }, + "autoload": { + "psr-4": { + "Rubix\\Client\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Rubix\\Client\\Tests\\": "tests/" + } + }, + "scripts": { + "build": [ + "@composer install", + "@analyze", + "@test", + "@check" + ], + "analyze": "phpstan analyse -c phpstan.neon", + "check": [ + "@putenv PHP_CS_FIXER_IGNORE_ENV=1", + "php-cs-fixer fix --config=.php_cs.dist -v --dry-run --using-cache=no" + ], + "fix": "php-cs-fixer fix --config=.php_cs.dist", + "test": "phpunit" + }, + "config": { + "preferred-install": "dist", + "sort-packages": true + }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/RubixML" + }, + { + "type": "github", + "url": "https://github.com/sponsors/andrewdalpino" + } + ], + "support": { + "issues": "https://github.com/RubixML/Server/issues", + "source": "https://github.com/RubixML/Server", + "chat": "https://t.me/RubixML", + "email": "support@andrewdalpino.com" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/examples/HTTP/client.php b/examples/HTTP/client.php new file mode 100644 index 0000000..94f9506 --- /dev/null +++ b/examples/HTTP/client.php @@ -0,0 +1,30 @@ + new Blob([255, 0, 0], 20.0), + 'green' => new Blob([0, 128, 0], 20.0), + 'blue' => new Blob([0, 0, 255], 20.0), +]); + +$dataset = $generator->generate(10); + +$predictions = $client->predict($dataset); + +print_r($predictions); + +for ($i = 0; $i < 100000; ++$i) { + $client->predict($dataset); +} diff --git a/examples/HTTP/server.php b/examples/HTTP/server.php new file mode 100644 index 0000000..efd69a3 --- /dev/null +++ b/examples/HTTP/server.php @@ -0,0 +1,37 @@ + new Blob([255, 0, 0], 10.0), + 'green' => new Blob([0, 128, 0], 10.0), + 'blue' => new Blob([0, 0, 255], 10.0), +]); + +$estimator = new GaussianNB(); + +$dataset = $generator->generate(100); + +$estimator->train($dataset); + +$logger = new Screen('server.log'); + +$server = new HTTPServer('127.0.0.1', 8000, null, [ + new AccessLogGenerator($logger), + new BasicAuthenticator([ + 'user' => 'secret', + ]), +], 5, new InMemoryCache(0)); + +$server->setLogger($logger); + +$server->serve($estimator); diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..62b99f8 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 8 + paths: + - 'src' + - 'tests' diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..3731af3 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,25 @@ + + + + + tests + + + + + src + + + + + + diff --git a/src/AsyncClient.php b/src/AsyncClient.php new file mode 100644 index 0000000..5311c38 --- /dev/null +++ b/src/AsyncClient.php @@ -0,0 +1,33 @@ + + */ + protected const RETRY_CODES = [ + 429, 503, + ]; + + /** + * The maximum number of times to retry the request before giving up. + * + * @var int + */ + protected int $maxRetries; + + /** + * The number of seconds to delay between retries before exponential backoff is applied. + * + * @var float + */ + protected float $initialDelay; + + /** + * @param int $maxRetries + * @param float $initialDelay + * @throws \Rubix\Client\Exceptions\InvalidArgumentException + */ + public function __construct(int $maxRetries = 3, float $initialDelay = 0.5) + { + if ($maxRetries < 0) { + throw new InvalidArgumentException('Max retries must be' + . " greater than 0, $maxRetries given."); + } + + if ($initialDelay < 0.0) { + throw new InvalidArgumentException('Initial delay must be' + . " greater than 0, $initialDelay given."); + } + + $this->maxRetries = $maxRetries; + $this->initialDelay = $initialDelay; + } + + /** + * Try the request. + * + * @param \Psr\Http\Message\RequestInterface $request + * @param callable $handler + * @param mixed[] $options + * @return \GuzzleHttp\Promise\PromiseInterface + */ + public function tryRequest(RequestInterface $request, callable $handler, array $options) : PromiseInterface + { + $retry = function (ResponseInterface $response) use ($request, $handler, $options) : PromiseInterface { + if (in_array($response->getStatusCode(), self::RETRY_CODES)) { + if ($options['tries_left']) { + usleep((int) ($options['delay'] * 1e6)); + + --$options['tries_left']; + $options['delay'] *= 2.0; + + return $this->tryRequest($request, $handler, $options); + } + } + + return new FulfilledPromise($response); + }; + + return $handler($request, $options)->then($retry); + } + + /** + * Return the higher-order function. + * + * @return callable + */ + public function __invoke() : callable + { + return function (callable $handler) : callable { + return function (RequestInterface $request, array $options) use ($handler) : PromiseInterface { + $options['tries_left'] = 1 + $this->maxRetries; + $options['delay'] = $this->initialDelay; + + return $this->tryRequest($request, $handler, $options); + }; + }; + } +} diff --git a/src/HTTP/Middleware/BasicAuthenticator.php b/src/HTTP/Middleware/BasicAuthenticator.php new file mode 100644 index 0000000..eba6d54 --- /dev/null +++ b/src/HTTP/Middleware/BasicAuthenticator.php @@ -0,0 +1,50 @@ +credentials = 'Basic ' . base64_encode("$username:$password"); + } + + /** + * Return the higher-order function. + * + * @return callable + */ + public function __invoke() : callable + { + return function (callable $handler) : callable { + return function (RequestInterface $request, array $options) use ($handler) : PromiseInterface { + $request = $request->withHeader('Authorization', $this->credentials); + + return $handler($request, $options); + }; + }; + } +} diff --git a/src/HTTP/Middleware/CompressRequestBody.php b/src/HTTP/Middleware/CompressRequestBody.php new file mode 100644 index 0000000..f4a7a79 --- /dev/null +++ b/src/HTTP/Middleware/CompressRequestBody.php @@ -0,0 +1,83 @@ + 9) { + throw new InvalidArgumentException('Level must be' + . " between 0 and 9, $level given."); + } + + if ($threshold < 0) { + throw new InvalidArgumentException('Threshold must be' + . " greater than 0, $threshold given."); + } + + $this->level = $level; + $this->threshold = $threshold; + } + + /** + * Return the higher-order function. + * + * @return callable + */ + public function __invoke() : callable + { + return function (callable $handler) : callable { + return function (RequestInterface $request, array $options) use ($handler) : PromiseInterface { + if ($request->getBody()->getSize() > $this->threshold) { + $data = gzencode($request->getBody(), $this->level); + + $request = $request->withBody(Utils::streamFor($data)) + ->withHeader('Content-Encoding', 'gzip'); + } + + return $handler($request, $options); + }; + }; + } +} diff --git a/src/HTTP/Middleware/Middleware.php b/src/HTTP/Middleware/Middleware.php new file mode 100644 index 0000000..4dae449 --- /dev/null +++ b/src/HTTP/Middleware/Middleware.php @@ -0,0 +1,13 @@ +credentials = "Bearer $token"; + } +} diff --git a/src/HTTP/Requests/JSONRequest.php b/src/HTTP/Requests/JSONRequest.php new file mode 100644 index 0000000..f6a6f40 --- /dev/null +++ b/src/HTTP/Requests/JSONRequest.php @@ -0,0 +1,22 @@ + 'application/json', + ]; + + /** + * @param string $method + * @param string $path + * @param mixed[]|null $json + */ + public function __construct(string $method, string $path, ?array $json = null) + { + parent::__construct($method, $path, self::HEADERS, JSON::encode($json)); + } +} diff --git a/src/HTTP/Requests/PredictRequest.php b/src/HTTP/Requests/PredictRequest.php new file mode 100644 index 0000000..224f566 --- /dev/null +++ b/src/HTTP/Requests/PredictRequest.php @@ -0,0 +1,18 @@ + $dataset->samples(), + ]); + } +} diff --git a/src/HTTP/Requests/ProbaRequest.php b/src/HTTP/Requests/ProbaRequest.php new file mode 100644 index 0000000..fec6236 --- /dev/null +++ b/src/HTTP/Requests/ProbaRequest.php @@ -0,0 +1,18 @@ + $dataset->samples(), + ]); + } +} diff --git a/src/HTTP/Requests/Request.php b/src/HTTP/Requests/Request.php new file mode 100644 index 0000000..bc7daff --- /dev/null +++ b/src/HTTP/Requests/Request.php @@ -0,0 +1,10 @@ + $dataset->samples(), + ]); + } +} diff --git a/src/Helpers/JSON.php b/src/Helpers/JSON.php new file mode 100644 index 0000000..b771fab --- /dev/null +++ b/src/Helpers/JSON.php @@ -0,0 +1,75 @@ + 'Rubix ML REST Client', + 'Accept' => 'application/json', + ]; + + protected const ACCEPTED_CONTENT_TYPES = [ + 'application/json', + ]; + + protected const MAX_TCP_PORT = 65535; + + /** + * The Guzzle HTTP client. + * + * @var \GuzzleHttp\Client + */ + protected \GuzzleHttp\Client $client; + + /** + * @param string $host + * @param int $port + * @param bool $secure + * @param \Rubix\Client\HTTP\Middleware\Middleware[] $middlewares + * @param float $timeout + * @param bool $verifyCertificate + * @throws \Rubix\Client\Exceptions\InvalidArgumentException + */ + public function __construct( + string $host = '127.0.0.1', + int $port = 8000, + bool $secure = false, + array $middlewares = [], + float $timeout = 0.0, + bool $verifyCertificate = true + ) { + if (empty($host)) { + throw new InvalidArgumentException('Host address cannot be empty.'); + } + + if ($port < 0 or $port > self::MAX_TCP_PORT) { + throw new InvalidArgumentException('Port number must be' + . ' between 0 and ' . self::MAX_TCP_PORT . ", $port given."); + } + + $stack = HandlerStack::create(); + + foreach ($middlewares as $middleware) { + if (!$middleware instanceof Middleware) { + throw new InvalidArgumentException('Middleware must' + . ' implement the Middleware interface.'); + } + + $stack->push($middleware()); + } + + if ($timeout < 0.0) { + throw new InvalidArgumentException('Timeout must be' + . " greater than 0, $timeout given."); + } + + $baseUri = ($secure ? 'https' : 'http') . "://$host:$port"; + + $this->client = new Guzzle([ + 'base_uri' => $baseUri, + 'headers' => self::HEADERS, + 'timeout' => $timeout, + 'verify' => $verifyCertificate, + 'handler' => $stack, + ]); + } + + /** + * Make a set of predictions on a dataset. + * + * @param \Rubix\ML\Datasets\Dataset $dataset + * @return (string|int|float)[] + */ + public function predict(Dataset $dataset) : array + { + return $this->predictAsync($dataset)->wait(); + } + + /** + * Make a set of predictions on a dataset and return a promise. + * + * @param \Rubix\ML\Datasets\Dataset $dataset + * @return \GuzzleHttp\Promise\PromiseInterface + */ + public function predictAsync(Dataset $dataset) : PromiseInterface + { + $request = new PredictRequest($dataset); + + return $this->client->sendAsync($request) + ->then([$this, 'parseResponseBody'], [$this, 'onError']) + ->then([$this, 'unpackPayload']) + ->then(function ($data) { + return $data['predictions']; + }); + } + + /** + * Return the joint probabilities of each sample in a dataset. + * + * @param \Rubix\ML\Datasets\Dataset $dataset + * @return array[] + */ + public function proba(Dataset $dataset) : array + { + return $this->probaAsync($dataset)->wait(); + } + + /** + * Compute the joint probabilities of the samples in a dataset and return a promise. + * + * @param \Rubix\ML\Datasets\Dataset $dataset + * @return \GuzzleHttp\Promise\PromiseInterface + */ + public function probaAsync(Dataset $dataset) : PromiseInterface + { + $request = new ProbaRequest($dataset); + + return $this->client->sendAsync($request) + ->then([$this, 'parseResponseBody'], [$this, 'onError']) + ->then([$this, 'unpackPayload']) + ->then(function ($data) { + return $data['probabilities']; + }); + } + + /** + * Return the anomaly scores of each sample in a dataset. + * + * @param \Rubix\ML\Datasets\Dataset $dataset + * @return float[] + */ + public function score(Dataset $dataset) : array + { + return $this->scoreAsync($dataset)->wait(); + } + + /** + * Compute the anomaly scores of the samples in a dataset and return a promise. + * + * @param \Rubix\ML\Datasets\Dataset $dataset + * @return \GuzzleHttp\Promise\PromiseInterface + */ + public function scoreAsync(Dataset $dataset) : PromiseInterface + { + $request = new ScoreRequest($dataset); + + return $this->client->sendAsync($request) + ->then([$this, 'parseResponseBody'], [$this, 'onError']) + ->then([$this, 'unpackPayload']) + ->then(function ($data) { + return $data['scores']; + }); + } + + /** + * Parse the response body and return a promise that resolves to an associative array. + * + * @internal + * + * @param \Psr\Http\Message\ResponseInterface $response + * @throws \Rubix\Client\Exceptions\RuntimeException + * @return \GuzzleHttp\Promise\Promise + */ + public function parseResponseBody(ResponseInterface $response) : Promise + { + $promise = new Promise(function () use (&$promise, $response) { + if ($response->hasHeader('Content-Type')) { + $type = $response->getHeaderLine('Content-Type'); + + switch ($type) { + case 'application/json': + $payload = JSON::decode($response->getBody()); + + break; + + default: + throw new RuntimeException('Unacceptable content' + . " type $type in the response body."); + } + + /** @var \GuzzleHttp\Promise\Promise $promise */ + $promise->resolve($payload); + } + }); + + return $promise; + } + + /** + * Unpack the response body data payload. + * + * @param mixed[] $body + * @return \GuzzleHttp\Promise\Promise + */ + public function unpackPayload(array $body) : Promise + { + $promise = new Promise(function () use (&$promise, $body) { + if (!isset($body['data'])) { + throw new RuntimeException('Data payload missing' + . ' from the response body.'); + } + + /** @var \GuzzleHttp\Promise\Promise $promise */ + $promise->resolve($body['data']); + }); + + return $promise; + } + + /** + * Rethrow a client exception from the server namespace. + * + * @internal + * + * @param \Exception $exception + * @throws \Rubix\Client\Exceptions\RuntimeException + */ + public function onError(Exception $exception) : void + { + throw new RuntimeException($exception->getMessage(), $exception->getCode(), $exception); + } +} diff --git a/tests/RESTClientTest.php b/tests/RESTClientTest.php new file mode 100644 index 0000000..186871c --- /dev/null +++ b/tests/RESTClientTest.php @@ -0,0 +1,41 @@ +client = new RESTClient('127.0.0.1', 8888, false, [ + new SharedTokenAuthenticator('secret'), + ], 0.0); + } + + /** + * @test + */ + public function build() : void + { + $this->assertInstanceOf(RESTClient::class, $this->client); + $this->assertInstanceOf(Client::class, $this->client); + $this->assertInstanceOf(AsyncClient::class, $this->client); + } +}