diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index e49cc180fc..57cc00af8f 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -36,6 +36,7 @@ use Illuminate\Session\TokenMismatchException; use Illuminate\Support\Arr; use Illuminate\Validation\ValidationException as LaravelValidationException; use Laravel\Passport\Exceptions\OAuthServerException as LaravelOAuthException; +use LaravelJsonApi\Core\Exceptions\JsonApiException; use League\OAuth2\Server\Exception\OAuthServerException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; use Symfony\Component\HttpFoundation\Response; @@ -63,6 +64,7 @@ class Handler extends ExceptionHandler HttpException::class, SuspiciousOperationException::class, BadHttpHeaderException::class, + JsonApiException::class, ]; /** diff --git a/app/JsonApi/V3/Accounts/AccountRepository.php b/app/JsonApi/V3/Accounts/AccountRepository.php new file mode 100644 index 0000000000..02c7d4b1f8 --- /dev/null +++ b/app/JsonApi/V3/Accounts/AccountRepository.php @@ -0,0 +1,59 @@ +withUserGroup($this->userGroup) + ->withServer($this->server) + ->withSchema($this->schema); + } + +} diff --git a/app/JsonApi/V3/Accounts/AccountResource.php b/app/JsonApi/V3/Accounts/AccountResource.php new file mode 100644 index 0000000000..fa824c7b93 --- /dev/null +++ b/app/JsonApi/V3/Accounts/AccountResource.php @@ -0,0 +1,160 @@ +resource->id; + } + + /** + * Get the resource's attributes. + * + * @param Request|null $request + * + * @return iterable + */ + public function attributes($request): iterable + { + return [ + 'created_at' => $this->resource->created_at, + 'updated_at' => $this->resource->updated_at, + 'name' => $this->resource->name, + 'iban' => '' === $this->resource->iban ? null : $this->resource->iban, + 'active' => $this->resource->active, + 'virtual_balance' => $this->resource->virtual_balance, + 'last_activity' => $this->resource->last_activity, + 'balance' => $this->resource->balance, + 'native_balance' => $this->resource->native_balance, + 'type' => $this->resource->name, + + // 'type' => strtolower($accountType), + // 'account_role' => $accountRole, + // 'currency_id' => $currencyId, + // 'currency_code' => $currencyCode, + // 'currency_symbol' => $currencySymbol, + // 'currency_decimal_places' => $decimalPlaces, + // 'current_balance' => app('steam')->bcround(app('steam')->balance($account, $date), $decimalPlaces), + // 'current_balance_date' => $date->toAtomString(), + // 'notes' => $this->repository->getNoteText($account), + // 'monthly_payment_date' => $monthlyPaymentDate, + // 'credit_card_type' => $creditCardType, + // 'account_number' => $this->repository->getMetaValue($account, 'account_number'), + // 'bic' => $this->repository->getMetaValue($account, 'BIC'), + // 'opening_balance' => $openingBalance, + // 'opening_balance_date' => $openingBalanceDate, + // 'liability_type' => $liabilityType, + // 'liability_direction' => $liabilityDirection, + // 'interest' => $interest, + // 'interest_period' => $interestPeriod, + // 'current_debt' => $this->repository->getMetaValue($account, 'current_debt'), + // 'include_net_worth' => $includeNetWorth, + // 'longitude' => $longitude, + // 'latitude' => $latitude, + // 'zoom_level' => $zoomLevel, + // 'id' => (string) $account->id, + // 'created_at' => $account->created_at->toAtomString(), + // 'updated_at' => $account->updated_at->toAtomString(), + // 'active' => $account->active, + // 'order' => $order, + // 'name' => $account->name, + // 'iban' => '' === (string) $account->iban ? null : $account->iban, + // 'account_number' => $this->accountMeta[$id]['account_number'] ?? null, + // 'type' => strtolower($accountType), + // 'account_role' => $accountRole, + // 'currency_id' => (string) $currency->id, + // 'currency_code' => $currency->code, + // 'currency_symbol' => $currency->symbol, + // 'currency_decimal_places' => $currency->decimal_places, + // + // 'native_currency_id' => (string) $this->default->id, + // 'native_currency_code' => $this->default->code, + // 'native_currency_symbol' => $this->default->symbol, + // 'native_currency_decimal_places' => $this->default->decimal_places, + // + // // balance: + // 'current_balance' => $balance, + // 'native_current_balance' => $nativeBalance, + // 'current_balance_date' => $this->getDate()->endOfDay()->toAtomString(), + // + // // balance difference + // 'balance_difference' => $balanceDiff, + // 'native_balance_difference' => $nativeBalanceDiff, + // 'balance_difference_start' => $diffStart, + // 'balance_difference_end' => $diffEnd, + // + // // more meta + // 'last_activity' => array_key_exists($id, $this->lastActivity) ? $this->lastActivity[$id]->toAtomString() : null, + // + // // liability stuff + // 'liability_type' => $liabilityType, + // 'liability_direction' => $liabilityDirection, + // 'interest' => $interest, + // 'interest_period' => $interestPeriod, + // 'current_debt' => $currentDebt, + // + // // object group + // 'object_group_id' => null !== $objectGroupId ? (string) $objectGroupId : null, + // 'object_group_order' => $objectGroupOrder, + // 'object_group_title' => $objectGroupTitle, + // 'notes' => $this->repository->getNoteText($account), + // 'monthly_payment_date' => $monthlyPaymentDate, + // 'credit_card_type' => $creditCardType, + // 'bic' => $this->repository->getMetaValue($account, 'BIC'), + // 'virtual_balance' => number_format((float) $account->virtual_balance, $decimalPlaces, '.', ''), + // 'opening_balance' => $openingBalance, + // 'opening_balance_date' => $openingBalanceDate, + // 'include_net_worth' => $includeNetWorth, + // 'longitude' => $longitude, + // 'latitude' => $latitude, + // 'zoom_level' => $zoomLevel, + ]; + } + + /** + * Get the resource's relationships. + * + * @param Request|null $request + * + * @return iterable + */ + public function relationships($request): iterable + { + return [ + $this->relation('user')->withData($this->resource->user), + //$this->relation('tags')->withData($this->resource->getTags()), + ]; + } +} diff --git a/app/JsonApi/V3/Accounts/AccountSchema.php b/app/JsonApi/V3/Accounts/AccountSchema.php new file mode 100644 index 0000000000..61ac9c8e35 --- /dev/null +++ b/app/JsonApi/V3/Accounts/AccountSchema.php @@ -0,0 +1,190 @@ +sortable()->readOnly(), + DateTime::make('updated_at')->sortable()->readOnly(), + Attribute::make('name')->sortable(), + Attribute::make('iban'), + Attribute::make('active'), + Attribute::make('virtual_balance'), + + Attribute::make('last_activity')->sortable(), + Attribute::make('balance')->sortable(), + Attribute::make('native_balance')->sortable(), + Attribute::make('type'), + + // fancy fields: + + + + //Attribute::make('current_balance')->sortable(), + + // 'type' => strtolower($accountType), + // 'account_role' => $accountRole, + // 'currency_id' => $currencyId, + // 'currency_code' => $currencyCode, + // 'currency_symbol' => $currencySymbol, + // 'currency_decimal_places' => $decimalPlaces, + // 'current_balance' => app('steam')->bcround(app('steam')->balance($account, $date), $decimalPlaces), + // 'current_balance_date' => $date->toAtomString(), + // 'notes' => $this->repository->getNoteText($account), + // 'monthly_payment_date' => $monthlyPaymentDate, + // 'credit_card_type' => $creditCardType, + // 'account_number' => $this->repository->getMetaValue($account, 'account_number'), + // 'bic' => $this->repository->getMetaValue($account, 'BIC'), + // 'opening_balance' => $openingBalance, + // 'opening_balance_date' => $openingBalanceDate, + // 'liability_type' => $liabilityType, + // 'liability_direction' => $liabilityDirection, + // 'interest' => $interest, + // 'interest_period' => $interestPeriod, + // 'current_debt' => $this->repository->getMetaValue($account, 'current_debt'), + // 'include_net_worth' => $includeNetWorth, + // 'longitude' => $longitude, + // 'latitude' => $latitude, + // 'zoom_level' => $zoomLevel, + // 'id' => (string) $account->id, + // 'created_at' => $account->created_at->toAtomString(), + // 'updated_at' => $account->updated_at->toAtomString(), + // 'active' => $account->active, + // 'order' => $order, + // 'name' => $account->name, + // 'iban' => '' === (string) $account->iban ? null : $account->iban, + // 'account_number' => $this->accountMeta[$id]['account_number'] ?? null, + // 'type' => strtolower($accountType), + // 'account_role' => $accountRole, + // 'currency_id' => (string) $currency->id, + // 'currency_code' => $currency->code, + // 'currency_symbol' => $currency->symbol, + // 'currency_decimal_places' => $currency->decimal_places, + // + // 'native_currency_id' => (string) $this->default->id, + // 'native_currency_code' => $this->default->code, + // 'native_currency_symbol' => $this->default->symbol, + // 'native_currency_decimal_places' => $this->default->decimal_places, + // + // // balance: + // 'current_balance' => $balance, + // 'native_current_balance' => $nativeBalance, + // 'current_balance_date' => $this->getDate()->endOfDay()->toAtomString(), + // + // // balance difference + // 'balance_difference' => $balanceDiff, + // 'native_balance_difference' => $nativeBalanceDiff, + // 'balance_difference_start' => $diffStart, + // 'balance_difference_end' => $diffEnd, + // + // // more meta + // 'last_activity' => array_key_exists($id, $this->lastActivity) ? $this->lastActivity[$id]->toAtomString() : null, + // + // // liability stuff + // 'liability_type' => $liabilityType, + // 'liability_direction' => $liabilityDirection, + // 'interest' => $interest, + // 'interest_period' => $interestPeriod, + // 'current_debt' => $currentDebt, + // + // // object group + // 'object_group_id' => null !== $objectGroupId ? (string) $objectGroupId : null, + // 'object_group_order' => $objectGroupOrder, + // 'object_group_title' => $objectGroupTitle, + // 'notes' => $this->repository->getNoteText($account), + // 'monthly_payment_date' => $monthlyPaymentDate, + // 'credit_card_type' => $creditCardType, + // 'bic' => $this->repository->getMetaValue($account, 'BIC'), + // 'virtual_balance' => number_format((float) $account->virtual_balance, $decimalPlaces, '.', ''), + // 'opening_balance' => $openingBalance, + // 'opening_balance_date' => $openingBalanceDate, + // 'include_net_worth' => $includeNetWorth, + // 'longitude' => $longitude, + // 'latitude' => $latitude, + // 'zoom_level' => $zoomLevel, + + ToOne::make('user'), +// ToMany::make('tags'), + ]; + } + + /** + * @inheritDoc + */ + public function pagination(): EnumerablePagination + { + return EnumerablePagination::make(); + } + + + /** + * @inheritDoc + */ + public function filters(): iterable + { + return [ + Filter::make('name'), + ]; + } + public function repository(): AccountRepository + { + $userGroup = $this->validateUserGroup(request()); + return AccountRepository::make() + ->withUserGroup($userGroup) + ->withServer($this->server) + ->withSchema($this); + } + + + +} diff --git a/app/JsonApi/V3/Accounts/Capabilities/AccountQuery.php b/app/JsonApi/V3/Accounts/Capabilities/AccountQuery.php new file mode 100644 index 0000000000..7ef2de6b8a --- /dev/null +++ b/app/JsonApi/V3/Accounts/Capabilities/AccountQuery.php @@ -0,0 +1,84 @@ +queryParameters->filter(); + $sort = $this->queryParameters->sortFields(); + $pagination = $this->filtersPagination($this->queryParameters->page()); + $needsAll = $this->validateParams('account', $sort); + $query = $this->userGroup->accounts(); + + if (!$needsAll) { + $query = $this->addPagination($query, $pagination); + } + $query = $this->addSortParams($query, $sort); + $query = $this->addFilterParams('account', $query, $filters); + + $collection = $query->get(['accounts.*']); + + // enrich data + $enrichment = new AccountEnrichment(); + $collection = $enrichment->enrich($collection); + + + // add filters after the query + + // add sort after the query + $collection = $this->sortCollection($collection, $sort); + + + return $collection; + +// var_dump($filters->value('name')); +// exit; + + + return Account::get(); + } +} diff --git a/app/JsonApi/V3/Server.php b/app/JsonApi/V3/Server.php new file mode 100644 index 0000000000..78a5beec6a --- /dev/null +++ b/app/JsonApi/V3/Server.php @@ -0,0 +1,42 @@ +sortable()->readOnly(), + DateTime::make('updatedAt')->sortable()->readOnly(), + HasMany::make('accounts'), + ]; + } + + /** + * Get the resource filters. + * + * @return array + */ + public function filters(): array + { + return [ + WhereIdIn::make($this), + ]; + } + + /** + * Get the resource paginator. + * + * @return Paginator|null + */ + public function pagination(): ?Paginator + { + return PagePagination::make(); + } + public function authorizable(): bool + { + return false; + } + +} diff --git a/app/Policies/AccountPolicy.php b/app/Policies/AccountPolicy.php new file mode 100644 index 0000000000..c1ed85f04d --- /dev/null +++ b/app/Policies/AccountPolicy.php @@ -0,0 +1,54 @@ +check() && $user->id === $account->user_id; + } + + /** + * Everybody can do this, but selection should limit to user. + * + * @return true + */ + public function viewAny(): bool + { + return auth()->check(); + } +} diff --git a/app/Policies/UserPolicy.php b/app/Policies/UserPolicy.php new file mode 100644 index 0000000000..4c6cec2c65 --- /dev/null +++ b/app/Policies/UserPolicy.php @@ -0,0 +1,29 @@ +userGroup; + } + + public function setUserGroup(UserGroup $userGroup): void + { + $this->userGroup = $userGroup; + } + + public function withUserGroup(UserGroup $userGroup): self { + $this->userGroup = $userGroup; + return $this; + } + +} diff --git a/app/Support/JsonApi/Enrichments/AccountEnrichment.php b/app/Support/JsonApi/Enrichments/AccountEnrichment.php new file mode 100644 index 0000000000..4f1343c6d9 --- /dev/null +++ b/app/Support/JsonApi/Enrichments/AccountEnrichment.php @@ -0,0 +1,79 @@ +collection = $collection; + + // do everything here: + $this->getLastActivity(); + $this->getMetaBalances(); + + return $this->collection; + } + + /** + * TODO this method refers to a single-use method inside Steam that could be moved here. + * @return void + */ + private function getLastActivity(): void + { + /** @var AccountRepositoryInterface $accountRepository */ + $accountRepository = app(AccountRepositoryInterface::class); + $lastActivity = $accountRepository->getLastActivity($this->collection); + foreach ($lastActivity as $row) { + $this->collection->where('id', $row['account_id'])->first()->last_activity = Carbon::parse($row['date_max'], config('app.timezone')); + } + } + + /** + * TODO this method refers to a single-use method inside Steam that could be moved here. + * @return void + */ + private function getMetaBalances(): void + { + try { + $array = app('steam')->balancesByAccountsConverted($this->collection, today()); + } catch (FireflyException $e) { + Log::error(sprintf('Could not load balances: %s', $e->getMessage())); + return; + } + foreach ($array as $accountId => $row) { + $this->collection->where('id', $accountId)->first()->balance = $row['balance']; + $this->collection->where('id', $accountId)->first()->native_balance = $row['native_balance']; + } + } +} diff --git a/app/Support/JsonApi/Enrichments/EnrichmentInterface.php b/app/Support/JsonApi/Enrichments/EnrichmentInterface.php new file mode 100644 index 0000000000..ecd409b8c5 --- /dev/null +++ b/app/Support/JsonApi/Enrichments/EnrichmentInterface.php @@ -0,0 +1,32 @@ +skip($skip)->take($pagination['size']); + } + + final protected function addSortParams(Builder $query, SortFields $sort): Builder + { + foreach ($sort->all() as $sortField) { + $query->orderBy($sortField->name(), $sortField->isAscending() ? 'ASC' : 'DESC'); + } + return $query; + } + + final protected function addFilterParams(string $class, Builder $query, ?FilterParameters $filters): Builder + { + if (null === $filters) { + return $query; + } + $config = config(sprintf('firefly.valid_query_filters.%s', $class)) ?? []; + if (count($filters->all()) === 0) { + return $query; + } + $query->where(function (Builder $q) use ($config, $filters) { + foreach ($filters->all() as $filter) { + if (in_array($filter->key(), $config, true)) { + foreach($filter->value() as $value) { + $q->where($filter->key(), 'LIKE', sprintf('%%%s%%', $value)); + } + } + } + }); + + return $query; + } + +} diff --git a/app/Support/JsonApi/FiltersPagination.php b/app/Support/JsonApi/FiltersPagination.php new file mode 100644 index 0000000000..612b6e3b09 --- /dev/null +++ b/app/Support/JsonApi/FiltersPagination.php @@ -0,0 +1,55 @@ + 1, + 'size' => $this->getPageSize(), + ]; + } + // cleanup page number + $pagination['number'] = (int) ($pagination['number'] ?? 1); + $pagination['number'] = min(65536, max($pagination['number'], 1)); + + // clean up page size + $pagination['size'] = (int) ($pagination['size'] ?? $this->getPageSize()); + $pagination['size'] = min(1337, max($pagination['size'], 1)); + + return $pagination; + } + + private function getPageSize(): int + { + if (auth()->check()) { + return (int) app('preferences')->get('listPageSize', 50)->data; + } + return 50; + } +} diff --git a/app/Support/JsonApi/SortsCollection.php b/app/Support/JsonApi/SortsCollection.php new file mode 100644 index 0000000000..30953e32a9 --- /dev/null +++ b/app/Support/JsonApi/SortsCollection.php @@ -0,0 +1,40 @@ +all() as $sortField) { + $collection = $sortField->isAscending() ? $collection->sortBy($sortField->name()) : $collection->sortByDesc($sortField->name()); + } + + return $collection; + } + +} diff --git a/app/Support/JsonApi/ValidateSortParameters.php b/app/Support/JsonApi/ValidateSortParameters.php new file mode 100644 index 0000000000..73d144d05f --- /dev/null +++ b/app/Support/JsonApi/ValidateSortParameters.php @@ -0,0 +1,43 @@ +all() as $field) { + if (in_array($field->name(), $config, true)) { + return true; + } + } + return false; + } + +} diff --git a/config/jsonapi.php b/config/jsonapi.php new file mode 100644 index 0000000000..eb990cac14 --- /dev/null +++ b/config/jsonapi.php @@ -0,0 +1,35 @@ + 'JsonApi', + + /* + |-------------------------------------------------------------------------- + | Servers + |-------------------------------------------------------------------------- + | + | A list of the JSON:API compliant APIs in your application, referred to + | as "servers". They must be listed below, with the array key being the + | unique name for each server, and the value being the fully-qualified + | class name of the server class. + */ + 'servers' => [ +// 'v1' => \App\JsonApi\V1\Server::class, +'v3' => Server::class, + ], +]; diff --git a/routes/api.php b/routes/api.php index f33b7800b3..151f63eb4c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,6 +22,19 @@ declare(strict_types=1); +use LaravelJsonApi\Laravel\Facades\JsonApiRoute; +use LaravelJsonApi\Laravel\Routing\ResourceRegistrar; +use LaravelJsonApi\Laravel\Http\Controllers\JsonApiController; + + +JsonApiRoute::server('v3') + ->prefix('v3') + ->resources(function (ResourceRegistrar $server) { + $server->resource('accounts', JsonApiController::class); + $server->resource('users', JsonApiController::class); + }); + + // V2 API route for Summary boxes // BASIC Route::group(