diff --git a/CHANGELOG.md b/CHANGELOG.md index 429b16a..48edf62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ +- 2.0.0 + - Move clients into their own repository + - INF and NAN string sample values are automatically converted to respective PHP constants + 1.0.1 - Do not use deprecated ReactPHP class names -1.0.0 +- 1.0.0 - Add pan and zoom to dashboard charts - Rename anomaly scores HTTP resource diff --git a/README.md b/README.md index e6ed570..a98ee5f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Rubix Server -Rubix Server is a library for bringing your trained [Rubix ML](https://github.com/RubixML/ML) models into production. Inference servers are stand-alone services that run on your private or public network and wrap your trained estimator in an API that can be queried locally or over the network in real-time using standard protocols. In addition, the library provides async-compatible client implementations for making queries to the server from your PHP applications. +Rubix Server is a library for deploying your [Rubix ML](https://github.com/RubixML/ML) models to production. Our server wraps your trained estimator in an API that can be queried using standard protocols. Included is a real-time dashboard for monitoring the health and throughput of your models. - **Optimized** for low latency predictions -- **Scalable** horizontally by adding more instances +- **Scale** by adding more instances - **Monitoring** with real-time analytics dashboard - **Robust** to common threats and failure modes @@ -20,7 +20,7 @@ A [Docker Image](https://gitlab.com/torchello/rubix-ml-server-docker) is availab - [PHP](https://php.net/manual/en/install.php) 7.4 or above ## Documentation -The latest documentation can be found below. +The latest documentation can be found in this README. ### Table of Contents - [Servers](#servers) @@ -30,13 +30,6 @@ The latest documentation can be found below. - [Basic Authenticator](#basic-authenticator) - [Shared Token Authenticator](#shared-token-authenticator) - [Trusted Clients](#trusted-clients) -- [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) - [Loggers](#loggers) - [File](#file) - [FAQs](#faqs) @@ -124,9 +117,9 @@ Interfaces: [Server](#servers), [Verbose](#verbose-interface) ```php use Rubix\Server\HTTPServer; -use Rubix\Server\HTTP\Middleware\Server\AccessLogGenerator; +use Rubix\Server\HTTP\Middleware\\AccessLogGenerator; use Rubix\Server\Loggers\File; -use Rubix\Server\HTTP\Middleware\Server\BasicAuthenticator; +use Rubix\Server\HTTP\Middleware\\BasicAuthenticator; use Rubix\Server\Services\Caches\InMemoryCache; $server = new HTTPServer('127.0.0.1', 443, '/cert.pem', [ @@ -178,7 +171,7 @@ Generates an HTTP access log using a format similar to the Apache log format. **Example** ```php -use Rubix\Server\HTTP\Middleware\Server\AccessLog; +use Rubix\Server\HTTP\Middleware\\AccessLog; use Rubix\Server\Loggers\File; $middleware = new AccessLog(new File('access.log')); @@ -203,7 +196,7 @@ An implementation of HTTP Basic Auth as described in [RFC7617](https://tools.iet **Example** ```php -use Rubix\Server\HTTP\Middleware\Server\BasicAuthenticator; +use Rubix\Server\HTTP\Middleware\\BasicAuthenticator; $middleware = new BasicAuthenticator([ 'morgan' => 'secret', @@ -225,7 +218,7 @@ Authenticates incoming requests using a shared key that is kept secret between t **Example** ```php -use Rubix\Server\HTTP\Middleware\Server\SharedTokenAuthenticator; +use Rubix\Server\HTTP\Middleware\\SharedTokenAuthenticator; $middleware = new SharedTokenAuthenticator([ 'secret', 'another-secret', @@ -243,172 +236,17 @@ A whitelist of clients that can access the server - all other connections will b **Example** ```php -use Rubix\Server\HTTP\Middleware\Server\TrustedClients; +use Rubix\Server\HTTP\Middleware\\TrustedClients; $middleware = new TrustedClients([ '127.0.0.1', '192.168.4.1', '45.63.67.15', ]); ``` ---- -### 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\Server\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\Server\RESTClient; -use Rubix\Server\HTTP\Middleware\Client\BasicAuthenticator; -use Rubix\Server\HTTP\Middleware\Client\CompressRequestBody; -use Rubix\Server\HTTP\Middleware\Client\BackoffAndRetry; -use Rubix\Server\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\Server\HTTP\Middleware\Client\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\Server\HTTP\Middleware\Client\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\Server\HTTP\Middleware\Client\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\Server\HTTP\Middleware\Client\SharedtokenAuthenticator; - -$middleware = new SharedTokenAuthenticator('secret'); -``` - -### Loggers +## Loggers PSR-3 compatible loggers for capturing important server events. -#### File +### File A simple append-only file logger. #### Parameters diff --git a/composer.json b/composer.json index a70bcbf..e92819a 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { "name": "rubix/server", "type": "library", - "description": "Serve your Rubix ML models in production with scalable stand-alone model servers.", + "description": "Deploy your Rubix ML models to production with scalable stand-alone inference servers.", "homepage": "https://github.com/RubixML/Server", "license": "MIT", "readme": "README.md", @@ -26,22 +26,21 @@ ], "require": { "php": ">=7.4", - "guzzlehttp/guzzle": "^7.2", "guzzlehttp/psr7": "^1.7", "psr/container": "^1.1", "psr/http-message": "^1.0", "psr/log": "^1.1", "react/http": "^1.1", - "rubix/ml": "^1.0", + "rubix/ml": "^2.0", "symfony/polyfill-php80": "^1.17", "webonyx/graphql-php": "^14.4" }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.0", - "phpstan/phpstan": "0.12.*", + "phpstan/phpstan": "^1.0", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan-phpunit": "0.12.*", - "phpunit/phpunit": "8.5.*" + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.0" }, "autoload": { "psr-4": { @@ -70,7 +69,10 @@ }, "config": { "preferred-install": "dist", - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "phpstan/extension-installer": true + } }, "funding": [ { diff --git a/examples/HTTP/client.php b/examples/HTTP/client.php deleted file mode 100644 index bcb88a4..0000000 --- a/examples/HTTP/client.php +++ /dev/null @@ -1,30 +0,0 @@ - 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 index 44344ee..20865c2 100644 --- a/examples/HTTP/server.php +++ b/examples/HTTP/server.php @@ -6,8 +6,8 @@ use Rubix\ML\Datasets\Generators\Agglomerate; use Rubix\ML\Classifiers\GaussianNB; use Rubix\Server\HTTPServer; -use Rubix\Server\HTTP\Middleware\Server\AccessLogGenerator; -use Rubix\Server\HTTP\Middleware\Server\BasicAuthenticator; +use Rubix\Server\HTTP\Middleware\AccessLogGenerator; +use Rubix\Server\HTTP\Middleware\BasicAuthenticator; use Rubix\Server\Services\Caches\InMemoryCache; use Rubix\ML\Loggers\Screen; diff --git a/phpstan.neon b/phpstan.neon index 62b99f8..1e78c9a 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,3 +3,4 @@ parameters: paths: - 'src' - 'tests' + checkGenericClassInNonGenericObjectType: false diff --git a/src/AsyncClient.php b/src/AsyncClient.php deleted file mode 100644 index 92d372f..0000000 --- a/src/AsyncClient.php +++ /dev/null @@ -1,33 +0,0 @@ - */ abstract public function routes() : array; } diff --git a/src/HTTP/Controllers/DashboardController.php b/src/HTTP/Controllers/DashboardController.php index a86512a..b0be7c5 100644 --- a/src/HTTP/Controllers/DashboardController.php +++ b/src/HTTP/Controllers/DashboardController.php @@ -27,7 +27,7 @@ public function __construct(SSEChannel $channel) /** * Return the routes this controller handles. * - * @return array[] + * @return array */ public function routes() : array { diff --git a/src/HTTP/Controllers/GraphQLController.php b/src/HTTP/Controllers/GraphQLController.php index 10ae696..e085c63 100644 --- a/src/HTTP/Controllers/GraphQLController.php +++ b/src/HTTP/Controllers/GraphQLController.php @@ -6,6 +6,7 @@ use Rubix\Server\Helpers\JSON; use Rubix\Server\HTTP\Middleware\Internal\DecompressRequestBody; use Rubix\Server\HTTP\Middleware\Internal\ParseRequestBody; +use Rubix\Server\HTTP\Middleware\Internal\ConvertRequestBodyConstants; use Rubix\Server\HTTP\Responses\Success; use Rubix\Server\HTTP\Responses\UnprocessableEntity; use Rubix\Server\Exceptions\ValidationException; @@ -44,7 +45,7 @@ public function __construct(Schema $schema, PromiseAdapter $adapter) /** * Return the routes this controller handles. * - * @return array[] + * @return array */ public function routes() : array { @@ -53,6 +54,7 @@ public function routes() : array 'POST' => [ new DecompressRequestBody(), new ParseRequestBody(), + new ConvertRequestBodyConstants(), $this, ], ], diff --git a/src/HTTP/Controllers/ModelController.php b/src/HTTP/Controllers/ModelController.php index ac6f878..62c3abb 100644 --- a/src/HTTP/Controllers/ModelController.php +++ b/src/HTTP/Controllers/ModelController.php @@ -8,6 +8,7 @@ use Rubix\Server\Exceptions\ValidationException; use Rubix\Server\HTTP\Middleware\Internal\DecompressRequestBody; use Rubix\Server\HTTP\Middleware\Internal\ParseRequestBody; +use Rubix\Server\HTTP\Middleware\Internal\ConvertRequestBodyConstants; use Rubix\Server\Helpers\JSON; use Psr\Http\Message\ServerRequestInterface; use React\Promise\Promise; @@ -33,7 +34,7 @@ public function __construct(Model $model) /** * Return the routes this controller handles. * - * @return array[] + * @return array */ public function routes() : array { @@ -45,6 +46,7 @@ public function routes() : array 'POST' => [ new DecompressRequestBody(), new ParseRequestBody(), + new ConvertRequestBodyConstants(), [$this, 'predict'], ], ], @@ -55,6 +57,7 @@ public function routes() : array 'POST' => [ new DecompressRequestBody(), new ParseRequestBody(), + new ConvertRequestBodyConstants(), [$this, 'proba'], ], ]; @@ -65,6 +68,7 @@ public function routes() : array 'POST' => [ new DecompressRequestBody(), new ParseRequestBody(), + new ConvertRequestBodyConstants(), [$this, 'score'], ], ]; diff --git a/src/HTTP/Controllers/ServerController.php b/src/HTTP/Controllers/ServerController.php index b1bb84b..bb378b9 100644 --- a/src/HTTP/Controllers/ServerController.php +++ b/src/HTTP/Controllers/ServerController.php @@ -27,7 +27,7 @@ public function __construct(Server $server) /** * Return the routes this controller handles. * - * @return array[] + * @return array */ public function routes() : array { diff --git a/src/HTTP/Controllers/StaticAssetsController.php b/src/HTTP/Controllers/StaticAssetsController.php index a5a48f9..ff267c1 100644 --- a/src/HTTP/Controllers/StaticAssetsController.php +++ b/src/HTTP/Controllers/StaticAssetsController.php @@ -66,7 +66,7 @@ public function __construct(string $basePath, Cache $cache) /** * Return the routes this controller handles. * - * @return array[] + * @return array */ public function routes() : array { diff --git a/src/HTTP/Middleware/Server/AccessLogGenerator.php b/src/HTTP/Middleware/AccessLogGenerator.php similarity index 97% rename from src/HTTP/Middleware/Server/AccessLogGenerator.php rename to src/HTTP/Middleware/AccessLogGenerator.php index b04fd7a..5712515 100644 --- a/src/HTTP/Middleware/Server/AccessLogGenerator.php +++ b/src/HTTP/Middleware/AccessLogGenerator.php @@ -1,6 +1,6 @@ - */ - 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\Server\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/Client/BasicAuthenticator.php b/src/HTTP/Middleware/Client/BasicAuthenticator.php deleted file mode 100644 index 8aa74ca..0000000 --- a/src/HTTP/Middleware/Client/BasicAuthenticator.php +++ /dev/null @@ -1,50 +0,0 @@ -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/Client/CompressRequestBody.php b/src/HTTP/Middleware/Client/CompressRequestBody.php deleted file mode 100644 index b659067..0000000 --- a/src/HTTP/Middleware/Client/CompressRequestBody.php +++ /dev/null @@ -1,83 +0,0 @@ - 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/Client/Middleware.php b/src/HTTP/Middleware/Client/Middleware.php deleted file mode 100644 index 1b71db7..0000000 --- a/src/HTTP/Middleware/Client/Middleware.php +++ /dev/null @@ -1,13 +0,0 @@ -credentials = "Bearer $token"; - } -} diff --git a/src/HTTP/Middleware/Internal/ConvertRequestBodyConstants.php b/src/HTTP/Middleware/Internal/ConvertRequestBodyConstants.php new file mode 100644 index 0000000..e4efe18 --- /dev/null +++ b/src/HTTP/Middleware/Internal/ConvertRequestBodyConstants.php @@ -0,0 +1,54 @@ + + */ + protected const REPLACEMENTS = [ + 'INF' => INF, + 'NAN' => NAN, + ]; + + /** + * @param mixed[] $sample + */ + protected function convertConstants(array &$sample) : void + { + $replace = function (&$value) { + if (is_string($value)) { + if (isset(self::REPLACEMENTS[$value])) { + $value = self::REPLACEMENTS[$value]; + } + } + }; + + array_walk($sample, $replace); + } + + /** + * Converts INF and NAN string sample values (if any) into respective PHP constants. + * + * @internal + * + * @param \Psr\Http\Message\ServerRequestInterface $request + * @param callable $next + * @return \React\Promise\PromiseInterface + */ + public function __invoke(ServerRequestInterface $request, callable $next) + { + $body = $request->getParsedBody(); + + if (is_array($body) && !empty($body['samples'])) { + array_walk($body['samples'], [$this, 'convertConstants']); + + $request = $request->withParsedBody($body); + } + + return $next($request); + } +} diff --git a/src/HTTP/Middleware/Server/Middleware.php b/src/HTTP/Middleware/Middleware.php similarity index 89% rename from src/HTTP/Middleware/Server/Middleware.php rename to src/HTTP/Middleware/Middleware.php index 1deafb4..e3c5f2d 100644 --- a/src/HTTP/Middleware/Server/Middleware.php +++ b/src/HTTP/Middleware/Middleware.php @@ -1,6 +1,6 @@ '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 deleted file mode 100644 index 19ddf38..0000000 --- a/src/HTTP/Requests/PredictRequest.php +++ /dev/null @@ -1,18 +0,0 @@ - $dataset->samples(), - ]); - } -} diff --git a/src/HTTP/Requests/ProbaRequest.php b/src/HTTP/Requests/ProbaRequest.php deleted file mode 100644 index 2a1a492..0000000 --- a/src/HTTP/Requests/ProbaRequest.php +++ /dev/null @@ -1,18 +0,0 @@ - $dataset->samples(), - ]); - } -} diff --git a/src/HTTP/Requests/Request.php b/src/HTTP/Requests/Request.php deleted file mode 100644 index cd52660..0000000 --- a/src/HTTP/Requests/Request.php +++ /dev/null @@ -1,10 +0,0 @@ - $dataset->samples(), - ]); - } -} diff --git a/src/HTTPServer.php b/src/HTTPServer.php index cc39e22..4d5cd78 100644 --- a/src/HTTPServer.php +++ b/src/HTTPServer.php @@ -13,7 +13,7 @@ use Rubix\Server\Services\SSEChannel; use Rubix\Server\Services\Caches\Cache; use Rubix\Server\Services\Caches\InMemoryCache; -use Rubix\Server\HTTP\Middleware\Server\Middleware; +use Rubix\Server\HTTP\Middleware\Middleware; use Rubix\Server\HTTP\Middleware\Internal\DispatchEvents; use Rubix\Server\HTTP\Middleware\Internal\AttachServerHeaders; use Rubix\Server\HTTP\Middleware\Internal\CatchServerErrors; @@ -97,7 +97,7 @@ class HTTPServer implements Server, Verbose /** * The HTTP middleware stack. * - * @var \Rubix\Server\HTTP\Middleware\Server\Middleware[] + * @var \Rubix\Server\HTTP\Middleware\Middleware[] */ protected array $middlewares; @@ -161,7 +161,7 @@ class HTTPServer implements Server, Verbose * @param string $host * @param int $port * @param string|null $cert - * @param \Rubix\Server\HTTP\Middleware\Server\Middleware[] $middlewares + * @param \Rubix\Server\HTTP\Middleware\Middleware[] $middlewares * @param int $maxConcurrentRequests * @param \Rubix\Server\Services\Caches\Cache $staticAssetsCache * @param int $sseReconnectBuffer diff --git a/src/Helpers/JSON.php b/src/Helpers/JSON.php index b5a9955..307a73d 100644 --- a/src/Helpers/JSON.php +++ b/src/Helpers/JSON.php @@ -34,7 +34,7 @@ class JSON * * @param mixed $value * @param int $options - * @param int $depth + * @param positive-int $depth * @throws \Rubix\Server\Exceptions\JSONException * @return string */ @@ -56,7 +56,7 @@ public static function encode($value, int $options = self::DEFAULT_OPTIONS, int * * @param string $data * @param int $options - * @param int $depth + * @param positive-int $depth * @throws \Rubix\Server\Exceptions\JSONException * @return mixed[] */ diff --git a/src/Listeners/CloseSSEChannels.php b/src/Listeners/CloseSSEChannels.php index 8171e40..04757f8 100644 --- a/src/Listeners/CloseSSEChannels.php +++ b/src/Listeners/CloseSSEChannels.php @@ -24,7 +24,7 @@ public function __construct(array $channels) /** * Return the events that this listener subscribes to. * - * @return array[] + * @return array> */ public function events() : array { diff --git a/src/Listeners/CloseSocket.php b/src/Listeners/CloseSocket.php index 03d2a25..ee9c992 100644 --- a/src/Listeners/CloseSocket.php +++ b/src/Listeners/CloseSocket.php @@ -25,7 +25,7 @@ public function __construct(Socket $socket) /** * Return the events that this listener subscribes to. * - * @return array[] + * @return array> */ public function events() : array { diff --git a/src/Listeners/DashboardEmitter.php b/src/Listeners/DashboardEmitter.php index e54d5d7..a9d2bff 100644 --- a/src/Listeners/DashboardEmitter.php +++ b/src/Listeners/DashboardEmitter.php @@ -28,7 +28,7 @@ public function __construct(SSEChannel $channel) /** * Return the events that this listener subscribes to. * - * @return array[] + * @return array> */ public function events() : array { diff --git a/src/Listeners/Listener.php b/src/Listeners/Listener.php index ca37334..41183ca 100644 --- a/src/Listeners/Listener.php +++ b/src/Listeners/Listener.php @@ -7,7 +7,7 @@ interface Listener /** * Return the events that this listener subscribes to and their handlers. * - * @return array[] + * @return array> */ public function events() : array; } diff --git a/src/Listeners/LogFailures.php b/src/Listeners/LogFailures.php index 415c9bc..ea61dca 100644 --- a/src/Listeners/LogFailures.php +++ b/src/Listeners/LogFailures.php @@ -26,7 +26,7 @@ public function __construct(LoggerInterface $logger) /** * Return the events that this listener subscribes to. * - * @return array[] + * @return array> */ public function events() : array { diff --git a/src/Listeners/RecordHTTPStats.php b/src/Listeners/RecordHTTPStats.php index d98179e..104cfc5 100644 --- a/src/Listeners/RecordHTTPStats.php +++ b/src/Listeners/RecordHTTPStats.php @@ -26,7 +26,7 @@ public function __construct(HTTPStats $httpStats) /** * Return the events that this listener subscribes to. * - * @return array[] + * @return array> */ public function events() : array { diff --git a/src/Listeners/StopTimers.php b/src/Listeners/StopTimers.php index 3790b33..9820996 100644 --- a/src/Listeners/StopTimers.php +++ b/src/Listeners/StopTimers.php @@ -34,7 +34,7 @@ public function __construct(Scheduler $scheduler, array $timers) /** * Return the events that this listener subscribes to. * - * @return array[] + * @return array> */ public function events() : array { diff --git a/src/Models/Model.php b/src/Models/Model.php index 6b80cba..c08c6e1 100644 --- a/src/Models/Model.php +++ b/src/Models/Model.php @@ -77,7 +77,7 @@ public function predict(Dataset $dataset) : array * * @param \Rubix\ML\Datasets\Dataset $dataset * @throws \Rubix\Server\Exceptions\RuntimeException - * @return array[] + * @return array> */ public function proba(Dataset $dataset) : array { diff --git a/src/RESTClient.php b/src/RESTClient.php deleted file mode 100644 index 7ccf676..0000000 --- a/src/RESTClient.php +++ /dev/null @@ -1,256 +0,0 @@ - '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\Server\HTTP\Middleware\Client\Middleware[] $middlewares - * @param float $timeout - * @param bool $verifyCertificate - * @throws \Rubix\Server\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\Server\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\Server\Exceptions\RuntimeException - */ - public function onError(Exception $exception) : void - { - throw new RuntimeException($exception->getMessage(), $exception->getCode(), $exception); - } -} diff --git a/src/Services/Routes.php b/src/Services/Routes.php index 4d5a54b..47ecfa8 100644 --- a/src/Services/Routes.php +++ b/src/Services/Routes.php @@ -26,7 +26,7 @@ class Routes implements ArrayAccess /** * The routes and their controllers. * - * @var array[] + * @var array> */ protected array $routes; @@ -70,7 +70,7 @@ public static function collect(array $controllers) : self } /** - * @param array[] $routes + * @param array> $routes * @throws \Rubix\Server\Exceptions\InvalidArgumentException */ public function __construct(array $routes) diff --git a/src/Services/Subscriptions.php b/src/Services/Subscriptions.php index b5438a4..8e67737 100644 --- a/src/Services/Subscriptions.php +++ b/src/Services/Subscriptions.php @@ -18,14 +18,14 @@ class Subscriptions implements ArrayAccess /** * The mapping of events to their handlers. * - * @var array[] + * @var array> */ protected array $subscriptions; /** * Subscribe an array of listeners to their events. * - * @param \Rubix\Server\Listeners\Listener[] $listeners + * @param array<\Rubix\Server\Listeners\Listener|callable> $listeners * @throws \InvalidArgumentException * @return self */ @@ -50,7 +50,7 @@ public static function subscribe(array $listeners) : self } /** - * @param array[] $subscriptions + * @param array> $subscriptions * @throws \Rubix\Server\Exceptions\InvalidArgumentException */ public function __construct(array $subscriptions) diff --git a/tests/HTTP/Middleware/Internal/NormalizeInfNanValuesTest.php b/tests/HTTP/Middleware/Internal/NormalizeInfNanValuesTest.php new file mode 100644 index 0000000..7aca67c --- /dev/null +++ b/tests/HTTP/Middleware/Internal/NormalizeInfNanValuesTest.php @@ -0,0 +1,99 @@ +middleware = new ConvertRequestBodyConstants(); + } + + /** + * @test + */ + public function build() : void + { + $this->assertInstanceOf(ConvertRequestBodyConstants::class, $this->middleware); + } + + /** + * @test + */ + public function emptySamples() : void + { + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn(['a' => 1]); + $request->expects($this->never())->method('withParsedBody'); + + $this->middleware->__invoke($request, function (ServerRequestInterface $request) { + return $request; + }); + } + + /** + * @test + */ + public function cleanSamples() : void + { + $body = [ + 'samples' => [ + [1, 2, 3], + [4, 5, 6], + ], + ]; + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn($body); + $request->expects($this->once())->method('withParsedBody')->with($body)->willReturn($request); + + $this->middleware->__invoke($request, function (ServerRequestInterface $request) { + return $request; + }); + } + + /** + * @test + */ + public function infNanSamples() : void + { + $body = [ + 'samples' => [ + [1, 'INF', 3], + [4, 5, 'NAN'], + ], + ]; + + $request = $this->createMock(ServerRequestInterface::class); + $request->method('getParsedBody')->willReturn($body); + $request + ->expects($this->once()) + ->method('withParsedBody') + ->with($this->callback(function (array $body) { + return $body['samples'][0] === [1, INF, 3] && + $body['samples'][1][0] == 4 && + $body['samples'][1][1] == 5 && + is_nan($body['samples'][1][2]); + }))->willReturn($request); + + $this->middleware->__invoke($request, function (ServerRequestInterface $request) { + return $request; + }); + } +} diff --git a/tests/HTTP/Middleware/Server/AccessLogGeneratorTest.php b/tests/HTTP/Middleware/Server/AccessLogGeneratorTest.php index 9bd4ecd..8135450 100644 --- a/tests/HTTP/Middleware/Server/AccessLogGeneratorTest.php +++ b/tests/HTTP/Middleware/Server/AccessLogGeneratorTest.php @@ -1,20 +1,20 @@ 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); - } -}