diff --git a/src/Api/Client.php b/src/Api/Client.php index ca8dc7a..4e867b3 100644 --- a/src/Api/Client.php +++ b/src/Api/Client.php @@ -2,12 +2,14 @@ namespace Spinen\Ncentral\Api; +use Exception; use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Exception\RequestException; use Illuminate\Support\Str; use RuntimeException; +use Spinen\Ncentral\Exceptions\ApiException; use Spinen\Ncentral\Exceptions\ClientConfigurationException; -use Spinen\Ncentral\Exceptions\TokenException; use Spinen\Version\Version; /** @@ -31,8 +33,9 @@ public function __construct( /** * Shortcut to 'DELETE' request * + * @throws ApiException * @throws GuzzleException - * @throws TokenException + * @throws RuntimeException */ // TODO: Enable this once they add endpoints that support delete // public function delete(string $path): ?array @@ -43,8 +46,9 @@ public function __construct( /** * Shortcut to 'GET' request * + * @throws ApiException * @throws GuzzleException - * @throws TokenException + * @throws RuntimeException */ public function get(string $path): ?array { @@ -54,6 +58,7 @@ public function get(string $path): ?array /** * Get, return, or refresh the token * + * @throws ApiException * @throws GuzzleException * @throws RuntimeException */ @@ -72,11 +77,38 @@ public function getVersion() return new Version(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'VERSION'); } + /** + * Process exception + * + * @throws ApiException + * @throws GuzzleException + * @throws RuntimeException + */ + protected function processException(GuzzleException $e): void + { + if (! is_a($e, RequestException::class)) { + throw $e; + } + + /** @var RequestException $e */ + $body = $e->getResponse()->getBody()->getContents(); + + $results = json_decode($body, true); + + throw new ApiException( + body: $body, + code: $results['status'], + message: $results['message'], + previous: $e, + ); + } + /** * Shortcut to 'POST' request * + * @throws ApiException * @throws GuzzleException - * @throws TokenException + * @throws RuntimeException */ public function post(string $path, array $data): ?array { @@ -86,8 +118,9 @@ public function post(string $path, array $data): ?array /** * Shortcut to 'PUT' request * + * @throws ApiException * @throws GuzzleException - * @throws TokenException + * @throws RuntimeException */ // TODO: Enable this once they add endpoints that support put // public function put(string $path, array $data): ?array @@ -98,8 +131,9 @@ public function post(string $path, array $data): ?array /** * Make an API call to Ncentral * + * @throws ApiException * @throws GuzzleException - * @throws TokenException + * @throws RuntimeException */ public function request(?string $path, ?array $data = [], ?string $method = 'GET'): ?array { @@ -115,7 +149,7 @@ public function request(?string $path, ?array $data = [], ?string $method = 'GET 'Content-Type' => 'application/json', 'User-Agent' => 'SPINEN/'.$this->getVersion(), ], - // 'body' => empty($data) ? null : json_encode($data), + 'body' => empty($data) ? null : json_encode($data), ], uri: $this->uri($path), ) @@ -123,10 +157,7 @@ public function request(?string $path, ?array $data = [], ?string $method = 'GET ->getContents(), ); } catch (GuzzleException $e) { - // TODO: Figure out what to do with this error - // TODO: Consider returning [] for 401's? - - throw $e; + $this->processException($e); } } @@ -135,6 +166,7 @@ public function request(?string $path, ?array $data = [], ?string $method = 'GET /** * Refresh a token * + * @throws ApiException * @throws GuzzleException * @throws RuntimeException */ @@ -165,16 +197,14 @@ public function refreshToken(): Token return $this->token; } catch (GuzzleException $e) { - // TODO: Figure out what to do with this error - // TODO: Consider returning [] for 401's? - - throw $e; + $this->processException($e); } } /** * Request a token * + * @throws ApiException * @throws GuzzleException * @throws RuntimeException */ @@ -204,10 +234,7 @@ public function requestToken(): Token return $this->token; } catch (GuzzleException $e) { - // TODO: Figure out what to do with this error - // TODO: Consider returning [] for 401's? - - throw $e; + $this->processException($e); } } @@ -287,7 +314,7 @@ public function setToken(Token|string $token): self * in the configs, but if a url is passed in as a second parameter then it is used. * If no url is found it will use the hard-coded v2 Ncentral API URL. */ - public function uri(string $path = null, string $url = null): string + public function uri(?string $path = null, ?string $url = null): string { if ($path && Str::startsWith($path, 'http')) { return $path; diff --git a/src/DetailedScheduledTask.php b/src/DetailedScheduledTask.php new file mode 100644 index 0000000..688d180 --- /dev/null +++ b/src/DetailedScheduledTask.php @@ -0,0 +1,50 @@ + 'int', + 'taskId' => 'int', + ]; + + /** + * Path to API endpoint. + */ + protected string $extra = '/status/details'; + + /** + * Path to API endpoint. + */ + protected string $path = '/scheduled-tasks'; + + /** + * The primary key for the model. + */ + protected string $primaryKey = 'taskId'; + + /** + * Is the model readonly? + */ + protected bool $readonlyModel = true; +} diff --git a/src/Exceptions/ApiException.php b/src/Exceptions/ApiException.php new file mode 100644 index 0000000..1ce6292 --- /dev/null +++ b/src/Exceptions/ApiException.php @@ -0,0 +1,29 @@ +body = $body; + } + + public function getBody(): string + { + return $this->body; + } +} diff --git a/src/Exceptions/TokenException.php b/src/Exceptions/TokenException.php deleted file mode 100644 index 18d7fc0..0000000 --- a/src/Exceptions/TokenException.php +++ /dev/null @@ -1,9 +0,0 @@ - [ + 'type' => 'LocalSystem', + 'username' => null, + 'password' => null, + ], + 'taskType' => 'AutomationPolicy', + ]; + /** * The attributes that should be cast to native types. * * @var array */ - protected $casts = []; + protected $casts = [ + 'applianceId' => 'int', + 'customerId' => 'int', + 'deviceId' => 'int', + 'isEnabled' => 'bool', + 'isReactive' => 'bool', + 'itemId' => 'int', + 'parentId' => 'int', + 'taskId' => 'int', + ]; /** * Path to API endpoint. */ protected string $path = '/scheduled-tasks'; + + /** + * The primary key for the model. + */ + protected string $primaryKey = 'taskId'; + + /** + * Accessor to get the details + * + * @throws NoClientException + */ + public function getDetailsAttribute(): DetailedScheduledTask + { + return (new Builder())->setClient($this->getClient()) + ->detailedScheduledTasks() + ->find($this->taskId); + } + + /** + * Any thing to add to the end of the path + */ + public function getExtra(): ?string + { + // N-able has create route different than get route + return $this->taskId ? null : '/direct'; + } + + /** + * Does the model allow updates? + */ + public function getReadonlyModel(): bool + { + // Toggle readonly for existing as you cannot update + return $this->exists; + } } diff --git a/src/Support/Builder.php b/src/Support/Builder.php index 4c07ead..0b7c586 100644 --- a/src/Support/Builder.php +++ b/src/Support/Builder.php @@ -6,17 +6,19 @@ use GuzzleHttp\Exception\GuzzleException; use Illuminate\Support\Arr; use Illuminate\Support\Collection as LaravelCollection; -use Illuminate\Support\Str; use Illuminate\Support\Traits\Conditionable; +use RuntimeException; use Spinen\Ncentral\Concerns\HasClient; use Spinen\Ncentral\Customer; +use Spinen\Ncentral\DetailedScheduledTask; use Spinen\Ncentral\Device; use Spinen\Ncentral\DeviceTask; +use Spinen\Ncentral\Exceptions\ApiException; use Spinen\Ncentral\Exceptions\InvalidRelationshipException; use Spinen\Ncentral\Exceptions\ModelNotFoundException; use Spinen\Ncentral\Exceptions\NoClientException; -use Spinen\Ncentral\Exceptions\TokenException; use Spinen\Ncentral\Health; +use Spinen\Ncentral\ScheduledTask; use Spinen\Ncentral\ServerInfo; /** @@ -58,9 +60,11 @@ class Builder */ protected $rootModels = [ 'customers' => Customer::class, + 'detailedScheduledTasks' => DetailedScheduledTask::class, 'devices' => Device::class, 'deviceTasks' => DeviceTask::class, 'health' => Health::class, + 'scheduledTasks' => ScheduledTask::class, 'serverInfo' => ServerInfo::class, ]; @@ -72,9 +76,13 @@ class Builder /** * Magic method to make builders for root models * + * @throws ApiException * @throws BadMethodCallException + * @throws GuzzleException + * @throws InvalidRelationshipException * @throws ModelNotFoundException * @throws NoClientException + * @throws RuntimeException */ public function __call(string $name, array $arguments) { @@ -88,11 +96,12 @@ public function __call(string $name, array $arguments) /** * Magic method to make builders appears as properties * + * @throws ApiException * @throws GuzzleException * @throws InvalidRelationshipException * @throws ModelNotFoundException * @throws NoClientException - * @throws TokenException + * @throws RuntimeException */ public function __get(string $name): Collection|Model|null { @@ -131,12 +140,13 @@ public function debug(bool $debug = true): self /** * Get Collection of class instances that match query * + * @throws ApiException * @throws GuzzleException * @throws InvalidRelationshipException * @throws NoClientException - * @throws TokenException + * @throws RuntimeException */ - public function get(array|string $properties = ['*'], string $extra = null): Collection|Model + public function get(array|string $properties = ['*'], ?string $extra = null): Collection|Model { $properties = Arr::wrap($properties); @@ -152,7 +162,7 @@ public function get(array|string $properties = ['*'], string $extra = null): Col $pageSize = $response['pageSize'] ?? null; // Peel off the key if exist - $response = $this->peelWrapperPropertyIfNeeded(Arr::wrap($response)); + $response = $this->getModel()->peelWrapperPropertyIfNeeded(Arr::wrap($response)); // Convert to a collection of filtered objects casted to the class return (new Collection((array_values($response) === $response) ? $response : [$response])) @@ -169,7 +179,7 @@ public function get(array|string $properties = ['*'], string $extra = null): Col ->setLinks($links) ->setPagination(count: $count, page: $page, pages: $pages, pageSize: $pageSize) // If never a collection, only return the first - ->unless($this->getModel()->collection, fn(Collection $c): Model => $c->first()); + ->unless($this->getModel()->collection, fn (Collection $c): Model => $c->first()); } /** @@ -195,22 +205,23 @@ public function getModel(): Model * * @throws InvalidRelationshipException */ - public function getPath(string $extra = null): ?string + public function getPath(?string $extra = null): ?string { $w = (array) $this->wheres; $id = Arr::pull($w, $this->getModel()->getKeyName()); return $this->getModel() - ->getPath($extra.(is_null($id) ? null : '/'.$id), $w); + ->getPath($extra.(is_null($id) ? null : '/'.$id).$this->getModel()->getExtra(), $w); } /** * Find specific instance of class * + * @throws ApiException * @throws GuzzleException * @throws InvalidRelationshipException * @throws NoClientException - * @throws TokenException + * @throws RuntimeException */ public function find(int|string $id, array|string $properties = ['*']): Model { @@ -222,7 +233,7 @@ public function find(int|string $id, array|string $properties = ['*']): Model /** * Order newest to oldest */ - public function latest(string $column = null): self + public function latest(?string $column = null): self { $column ??= $this->getModel()->getCreatedAtColumn(); @@ -284,7 +295,7 @@ public function newInstanceForModel(string $model): self /** * Order oldest to newest */ - public function oldest(string $column = null): self + public function oldest(?string $column = null): self { $column ??= $this->getModel()->getCreatedAtColumn(); @@ -317,7 +328,7 @@ public function orderByDesc(string $column): self * * @throws InvalidRelationshipException */ - public function page(int|string $number, int|string $size = null): self + public function page(int|string $number, int|string|null $size = null): self { return $this->where('pageNumber', (int) $number) ->when($size, fn (self $b): self => $b->paginate($size)); @@ -328,24 +339,12 @@ public function page(int|string $number, int|string $size = null): self * * @throws InvalidRelationshipException */ - public function paginate(int|string $size = null): self + public function paginate(int|string|null $size = null): self { return $this->unless($size, fn (self $b): self => $b->where('paginate', false)) ->when($size, fn (self $b): self => $b->where('paginate', true)->where('pageSize', (int) $size)); } - /** - * Peel of the wrapping property if it exist. - * - * @throws InvalidRelationshipException - */ - protected function peelWrapperPropertyIfNeeded(array $properties): array - { - return array_key_exists($this->getModel()->getResponseKey(), $properties) - ? $properties[$this->getModel()->getResponseKey()] - : $properties; - } - /** * Set the class to cast the response * diff --git a/src/Support/Collection.php b/src/Support/Collection.php index d019c0f..e1ec63d 100644 --- a/src/Support/Collection.php +++ b/src/Support/Collection.php @@ -87,7 +87,7 @@ public function setLinks(array $links = []): self /** * Set pagination */ - public function setPagination(int $count = null, int $page = null, int $pages = null, int $pageSize = null): self + public function setPagination(?int $count = null, ?int $page = null, ?int $pages = null, ?int $pageSize = null): self { $this->pagination = array_merge($this->pagination, compact('count', 'page', 'pages', 'pageSize')); diff --git a/src/Support/Model.php b/src/Support/Model.php index ed629b6..53be5eb 100644 --- a/src/Support/Model.php +++ b/src/Support/Model.php @@ -9,19 +9,22 @@ use Illuminate\Database\Eloquent\Concerns\HasAttributes; use Illuminate\Database\Eloquent\Concerns\HasTimestamps; use Illuminate\Database\Eloquent\Concerns\HidesAttributes; +use Illuminate\Database\Eloquent\InvalidCastException; use Illuminate\Database\Eloquent\JsonEncodingException; +use Illuminate\Database\Eloquent\MissingAttributeException; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Date; use Illuminate\Support\Str; use Illuminate\Support\Traits\Conditionable; use JsonSerializable; use LogicException; +use RuntimeException; use Spinen\Ncentral\Concerns\HasClient; +use Spinen\Ncentral\Exceptions\ApiException; use Spinen\Ncentral\Exceptions\InvalidRelationshipException; use Spinen\Ncentral\Exceptions\ModelNotFoundException; use Spinen\Ncentral\Exceptions\ModelReadonlyException; use Spinen\Ncentral\Exceptions\NoClientException; -use Spinen\Ncentral\Exceptions\TokenException; use Spinen\Ncentral\Exceptions\UnableToSaveException; use Spinen\Ncentral\Support\Relations\BelongsTo; use Spinen\Ncentral\Support\Relations\ChildOf; @@ -60,6 +63,11 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab */ public bool $exists = false; + /** + * Extra path to add to end of API endpoint. + */ + protected string $extra; + /** * Indicates if the IDs are auto-incrementing. */ @@ -150,7 +158,7 @@ abstract class Model implements Arrayable, ArrayAccess, Jsonable, JsonSerializab /** * Model constructor. */ - public function __construct(?array $attributes = [], Model $parentModel = null) + public function __construct(?array $attributes = [], ?Model $parentModel = null) { // All dates from API comes as epoch with milliseconds $this->dateFormat = 'Uv'; @@ -189,7 +197,7 @@ public function __isset(string $key): bool */ public function __set($key, $value) { - if ($this->readonlyModel) { + if ($this->getReadonlyModel()) { throw new ModelReadonlyException(); } @@ -297,14 +305,16 @@ protected function convertBoolToString(mixed $value): mixed /** * Delete the model from Ncentral * + * @throws InvalidCastException + * @throws LogicException + * @throws MissingAttributeException * @throws NoClientException - * @throws TokenException */ // TODO: Enable this once they add endpoints that support delete // public function delete(): bool // { // // TODO: Make sure that the model supports being deleted - // if ($this->readonlyModel) { + // if ($this->getReadonlyModel()) { // return false; // } @@ -343,6 +353,14 @@ public function getDefaultWheres(array $query = []): array ]; } + /** + * Any thing to add to the end of the path + */ + public function getExtra(): ?string + { + return $this->extra ?? null; + } + /** * Get the value indicating whether the IDs are incrementing. */ @@ -405,11 +423,13 @@ public function getPath($extra = null, array $query = []): ?string $path = rtrim($this->path, '/'); // If have an id, then put it on the end - // NOTE: Ncentral treats creates & updates the same, so only on existing if ($this->exist && $this->getKey()) { $path .= '/'.$this->getKey(); } + // Use the supplied extra or check if the model has an extra property + $extra ??= $this->getExtra(); + // Stick any extra things on the end if (! is_null($extra)) { $path .= '/'.ltrim($extra, '/'); @@ -427,6 +447,14 @@ public function getPath($extra = null, array $query = []): ?string return $path; } + /** + * Does the model allow updates? + */ + public function getReadonlyModel(): bool + { + return $this->readonlyModel ?? false; + } + /** * Get a relationship value from a method. * @@ -593,7 +621,7 @@ public function offsetGet($offset): mixed */ public function offsetSet($offset, $value): void { - if ($this->readonlyModel) { + if ($this->getReadonlyModel()) { throw new ModelReadonlyException(); } @@ -608,6 +636,18 @@ public function offsetUnset($offset): void unset($this->attributes[$offset], $this->relations[$offset]); } + /** + * Peel of the wrapping property if it exist. + * + * @throws InvalidRelationshipException + */ + public function peelWrapperPropertyIfNeeded(array $properties): array + { + return array_key_exists($this->getResponseKey(), $properties) + ? $properties[$this->getResponseKey()] + : $properties; + } + /** * Laravel allows control of accessing missing attributes, so we just return false * @@ -643,13 +683,16 @@ public function relationResolver($class, $key) /** * Save the model in Ncentral * + * @throws ApiException + * @throws InvalidCastException + * @throws LogicException + * @throws MissingAttributeException * @throws NoClientException - * @throws TokenException + * @throws RuntimeException */ public function save(): bool { - // TODO: Make sure that the model supports being saved - if ($this->readonlyModel) { + if ($this->getReadonlyModel()) { return false; } @@ -659,18 +702,18 @@ public function save(): bool } $response = $this->getClient() - ->post($this->getPath(), [$this->toArray()]); + ->post($this->getPath(), $this->toArray()); $this->exists = true; $this->wasRecentlyCreated = true; // Reset the model with the results as we get back the full model - $this->setRawAttributes($response, true); + $this->setRawAttributes($this->peelWrapperPropertyIfNeeded($response), true); return true; - } catch (GuzzleException $e) { - // TODO: Do something with the error + } catch (RuntimeException $e) { + // TODO: Should we do something with the error? return false; } @@ -679,8 +722,12 @@ public function save(): bool /** * Save the model in Ncentral, but raise error if fail * + * @throws ApiException + * @throws InvalidCastException + * @throws LogicException + * @throws MissingAttributeException * @throws NoClientException - * @throws TokenException + * @throws RuntimeException * @throws UnableToSaveException */ public function saveOrFail(): bool @@ -695,7 +742,18 @@ public function saveOrFail(): bool /** * Set the readonly * - * @param bool $readonly + * @return $this + */ + public function setExtra($extra = null): self + { + $this->extra = $extra; + + return $this; + } + + /** + * Set the readonly + * * @return $this */ public function setReadonly($readonly = true): self diff --git a/src/Support/Relations/BelongsTo.php b/src/Support/Relations/BelongsTo.php index 6917cfd..c258ad8 100644 --- a/src/Support/Relations/BelongsTo.php +++ b/src/Support/Relations/BelongsTo.php @@ -3,9 +3,10 @@ namespace Spinen\Ncentral\Support\Relations; use GuzzleHttp\Exception\GuzzleException; +use RuntimeException; +use Spinen\Ncentral\Exceptions\ApiException; use Spinen\Ncentral\Exceptions\InvalidRelationshipException; use Spinen\Ncentral\Exceptions\NoClientException; -use Spinen\Ncentral\Exceptions\TokenException; use Spinen\Ncentral\Support\Builder; use Spinen\Ncentral\Support\Model; @@ -58,10 +59,11 @@ public function getForeignKeyName(): string /** * Get the results of the relationship. * + * @throws ApiException * @throws GuzzleException * @throws InvalidRelationshipException * @throws NoClientException - * @throws TokenException + * @throws RuntimeException */ public function getResults(): ?Model { diff --git a/src/Support/Relations/HasMany.php b/src/Support/Relations/HasMany.php index 598e89e..4efefc6 100644 --- a/src/Support/Relations/HasMany.php +++ b/src/Support/Relations/HasMany.php @@ -3,9 +3,10 @@ namespace Spinen\Ncentral\Support\Relations; use GuzzleHttp\Exception\GuzzleException; +use RuntimeException; +use Spinen\Ncentral\Exceptions\ApiException; use Spinen\Ncentral\Exceptions\InvalidRelationshipException; use Spinen\Ncentral\Exceptions\NoClientException; -use Spinen\Ncentral\Exceptions\TokenException; use Spinen\Ncentral\Support\Collection; /** @@ -16,10 +17,11 @@ class HasMany extends Relation /** * Get the results of the relationship. * + * @throws ApiException * @throws GuzzleException * @throws InvalidRelationshipException * @throws NoClientException - * @throws TokenException + * @throws RuntimeException */ public function getResults(): Collection {