From 9b2263c7bb5555190ef743d5a3599669658aaa11 Mon Sep 17 00:00:00 2001 From: James Cole Date: Fri, 15 Aug 2025 11:28:23 +0200 Subject: [PATCH] Match exchange rate API with API docs. --- .../DestroyController.php | 21 ++--- .../CurrencyExchangeRate/ShowController.php | 20 ++++- .../CurrencyExchangeRate/StoreController.php | 39 +++++++++ .../CurrencyExchangeRate/UpdateController.php | 30 +++++-- .../CurrencyExchangeRate/DestroyRequest.php | 2 +- .../StoreByDateRequest.php | 87 +++++++++++++++++++ .../CurrencyExchangeRate/UpdateRequest.php | 2 + .../ExchangeRate/ExchangeRateRepository.php | 42 +++++---- .../ExchangeRateRepositoryInterface.php | 1 + app/Transformers/ExchangeRateTransformer.php | 2 + routes/api.php | 12 +-- 11 files changed, 216 insertions(+), 42 deletions(-) create mode 100644 app/Api/V1/Requests/Models/CurrencyExchangeRate/StoreByDateRequest.php diff --git a/app/Api/V1/Controllers/Models/CurrencyExchangeRate/DestroyController.php b/app/Api/V1/Controllers/Models/CurrencyExchangeRate/DestroyController.php index 30a3b79677..9ba01de025 100644 --- a/app/Api/V1/Controllers/Models/CurrencyExchangeRate/DestroyController.php +++ b/app/Api/V1/Controllers/Models/CurrencyExchangeRate/DestroyController.php @@ -59,23 +59,24 @@ class DestroyController extends Controller public function destroy(DestroyRequest $request, TransactionCurrency $from, TransactionCurrency $to): JsonResponse { - $date = $request->getDate(); - if (!$date instanceof Carbon) { - throw new ValidationException('Date is required'); - } - $rate = $this->repository->getSpecificRateOnDate($from, $to, $date); - if (!$rate instanceof CurrencyExchangeRate) { - throw new NotFoundHttpException(); - } - $this->repository->deleteRate($rate); + $this->repository->deleteRates($from, $to); return response()->json([], 204); } - public function destroySingle(CurrencyExchangeRate $exchangeRate): JsonResponse + public function destroySingleById(CurrencyExchangeRate $exchangeRate): JsonResponse { $this->repository->deleteRate($exchangeRate); + return response()->json([], 204); + } + public function destroySingleByDate(TransactionCurrency $from, TransactionCurrency $to, Carbon $date): JsonResponse + { + $exchangeRate = $this->repository->getSpecificRateOnDate($from, $to, $date); + if(null !== $exchangeRate) { + $this->repository->deleteRate($exchangeRate); + } + return response()->json([], 204); } } diff --git a/app/Api/V1/Controllers/Models/CurrencyExchangeRate/ShowController.php b/app/Api/V1/Controllers/Models/CurrencyExchangeRate/ShowController.php index bea0bb1078..abda7e7761 100644 --- a/app/Api/V1/Controllers/Models/CurrencyExchangeRate/ShowController.php +++ b/app/Api/V1/Controllers/Models/CurrencyExchangeRate/ShowController.php @@ -24,6 +24,7 @@ declare(strict_types=1); namespace FireflyIII\Api\V1\Controllers\Models\CurrencyExchangeRate; +use Carbon\Carbon; use FireflyIII\Api\V1\Controllers\Controller; use FireflyIII\Enums\UserRoleEnum; use FireflyIII\Models\CurrencyExchangeRate; @@ -33,6 +34,7 @@ use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait; use FireflyIII\Transformers\ExchangeRateTransformer; use Illuminate\Http\JsonResponse; use Illuminate\Pagination\LengthAwarePaginator; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Class ShowController @@ -76,7 +78,7 @@ class ShowController extends Controller ; } - public function showSingle(CurrencyExchangeRate $exchangeRate): JsonResponse + public function showSingleById(CurrencyExchangeRate $exchangeRate): JsonResponse { $transformer = new ExchangeRateTransformer(); $transformer->setParameters($this->parameters); @@ -86,4 +88,20 @@ class ShowController extends Controller ->header('Content-Type', self::CONTENT_TYPE) ; } + + public function showSingleByDate(TransactionCurrency $from, TransactionCurrency $to, Carbon $date): JsonResponse + { + $transformer = new ExchangeRateTransformer(); + $transformer->setParameters($this->parameters); + + $exchangeRate = $this->repository->getSpecificRateOnDate($from, $to, $date); + if(null === $exchangeRate) { + throw new NotFoundHttpException(); + } + + return response() + ->api($this->jsonApiObject(self::RESOURCE_KEY, $exchangeRate, $transformer)) + ->header('Content-Type', self::CONTENT_TYPE) + ; + } } diff --git a/app/Api/V1/Controllers/Models/CurrencyExchangeRate/StoreController.php b/app/Api/V1/Controllers/Models/CurrencyExchangeRate/StoreController.php index 8353d637a2..3e2d21dae2 100644 --- a/app/Api/V1/Controllers/Models/CurrencyExchangeRate/StoreController.php +++ b/app/Api/V1/Controllers/Models/CurrencyExchangeRate/StoreController.php @@ -24,14 +24,19 @@ declare(strict_types=1); namespace FireflyIII\Api\V1\Controllers\Models\CurrencyExchangeRate; +use Carbon\Carbon; +use FireflyIII\Api\V1\Requests\Models\CurrencyExchangeRate\StoreByDateRequest; use FireflyIII\Enums\UserRoleEnum; use FireflyIII\Models\CurrencyExchangeRate; use FireflyIII\Api\V1\Requests\Models\CurrencyExchangeRate\StoreRequest; use FireflyIII\Api\V1\Controllers\Controller; +use FireflyIII\Models\TransactionCurrency; use FireflyIII\Repositories\ExchangeRate\ExchangeRateRepositoryInterface; use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait; use FireflyIII\Transformers\ExchangeRateTransformer; use Illuminate\Http\JsonResponse; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; class StoreController extends Controller { @@ -54,6 +59,40 @@ class StoreController extends Controller ); } + public function storeByDate(StoreByDateRequest $request, Carbon $date): JsonResponse { + + $data = $request->getAll(); + $from = $request->getFromCurrency(); + $collection = new Collection(); + foreach($data['rates'] as $key => $rate) { + $to = TransactionCurrency::where('code', $key)->first(); + if(null === $to) { + continue; // should not happen. + } + $existing = $this->repository->getSpecificRateOnDate($from, $to, $date); + if(null !== $existing) { + // update existing rate. + $existing = $this->repository->updateExchangeRate($existing, $rate); + $collection->push($existing); + continue; + } + if(null === $existing) { + $new = $this->repository->storeExchangeRate($from, $to, $rate, $date); + $collection->push($new); + } + } + + $count = $collection->count(); + $paginator = new LengthAwarePaginator($collection, $count, $count, 1); + $transformer = new ExchangeRateTransformer(); + $transformer->setParameters($this->parameters); // give params to transformer + + return response() + ->json($this->jsonApiList(self::RESOURCE_KEY, $paginator, $transformer)) + ->header('Content-Type', self::CONTENT_TYPE) + ; + } + public function store(StoreRequest $request): JsonResponse { $date = $request->getDate(); diff --git a/app/Api/V1/Controllers/Models/CurrencyExchangeRate/UpdateController.php b/app/Api/V1/Controllers/Models/CurrencyExchangeRate/UpdateController.php index 788faf874d..99b4d04bbc 100644 --- a/app/Api/V1/Controllers/Models/CurrencyExchangeRate/UpdateController.php +++ b/app/Api/V1/Controllers/Models/CurrencyExchangeRate/UpdateController.php @@ -24,21 +24,24 @@ declare(strict_types=1); namespace FireflyIII\Api\V1\Controllers\Models\CurrencyExchangeRate; -use FireflyIII\Api\V1\Requests\Models\CurrencyExchangeRate\UpdateRequest; +use Carbon\Carbon; use FireflyIII\Api\V1\Controllers\Controller; +use FireflyIII\Api\V1\Requests\Models\CurrencyExchangeRate\UpdateRequest; use FireflyIII\Enums\UserRoleEnum; use FireflyIII\Models\CurrencyExchangeRate; +use FireflyIII\Models\TransactionCurrency; use FireflyIII\Repositories\ExchangeRate\ExchangeRateRepositoryInterface; use FireflyIII\Support\Http\Api\ValidatesUserGroupTrait; use FireflyIII\Transformers\ExchangeRateTransformer; use Illuminate\Http\JsonResponse; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class UpdateController extends Controller { use ValidatesUserGroupTrait; public const string RESOURCE_KEY = 'exchange-rates'; - protected array $acceptedRoles = [UserRoleEnum::OWNER]; + protected array $acceptedRoles = [UserRoleEnum::OWNER]; private ExchangeRateRepositoryInterface $repository; public function __construct() @@ -54,7 +57,7 @@ class UpdateController extends Controller ); } - public function update(UpdateRequest $request, CurrencyExchangeRate $exchangeRate): JsonResponse + public function updateById(UpdateRequest $request, CurrencyExchangeRate $exchangeRate): JsonResponse { $date = $request->getDate(); $rate = $request->getRate(); @@ -64,7 +67,24 @@ class UpdateController extends Controller return response() ->api($this->jsonApiObject(self::RESOURCE_KEY, $exchangeRate, $transformer)) - ->header('Content-Type', self::CONTENT_TYPE) - ; + ->header('Content-Type', self::CONTENT_TYPE); + } + + public function updateByDate(UpdateRequest $request, TransactionCurrency $from, TransactionCurrency $to, Carbon $date): JsonResponse + { + $exchangeRate = $this->repository->getSpecificRateOnDate($from, $to, $date); + if (null === $exchangeRate) { + throw new NotFoundHttpException(); + } + $date = $request->getDate(); + $rate = $request->getRate(); + $exchangeRate = $this->repository->updateExchangeRate($exchangeRate, $rate, $date); + + $transformer = new ExchangeRateTransformer(); + $transformer->setParameters($this->parameters); + + return response() + ->api($this->jsonApiObject(self::RESOURCE_KEY, $exchangeRate, $transformer)) + ->header('Content-Type', self::CONTENT_TYPE); } } diff --git a/app/Api/V1/Requests/Models/CurrencyExchangeRate/DestroyRequest.php b/app/Api/V1/Requests/Models/CurrencyExchangeRate/DestroyRequest.php index 1762d7348c..82ff8ca3de 100644 --- a/app/Api/V1/Requests/Models/CurrencyExchangeRate/DestroyRequest.php +++ b/app/Api/V1/Requests/Models/CurrencyExchangeRate/DestroyRequest.php @@ -45,7 +45,7 @@ class DestroyRequest extends FormRequest public function rules(): array { return [ - 'date' => 'required|date|after:1970-01-02|before:2038-01-17', + // 'date' => 'required|date|after:1970-01-02|before:2038-01-17', ]; } } diff --git a/app/Api/V1/Requests/Models/CurrencyExchangeRate/StoreByDateRequest.php b/app/Api/V1/Requests/Models/CurrencyExchangeRate/StoreByDateRequest.php new file mode 100644 index 0000000000..17c68b1183 --- /dev/null +++ b/app/Api/V1/Requests/Models/CurrencyExchangeRate/StoreByDateRequest.php @@ -0,0 +1,87 @@ + $this->get('from'), + 'rates' => $this->get('rates', []), + ]; + } + + public function getFromCurrency(): TransactionCurrency + { + return TransactionCurrency::where('code', $this->get('from'))->first(); + } + + /** + * The rules that the incoming request must be matched against. + */ + public function rules(): array + { + return [ + 'from' => 'required|exists:transaction_currencies,code', + 'rates' => 'required|array', + 'rates.*' => 'required|numeric|min:0.0000000001', + ]; + } + + public function withValidator(Validator $validator): void + { + $from = $this->getFromCurrency(); + + $validator->after( + static function (Validator $validator) use ($from): void { + $data = $validator->getData(); + $rates = $data['rates'] ?? []; + if (0 === count($rates)) { + $validator->errors()->add('rates', 'No rates given.'); + return; + } + foreach ($rates as $key => $entry) { + if ($key === $from->code) { + $validator->errors()->add(sprintf('rates.%s', $key), sprintf('Cannot convert from "%s" to itself.', $key)); + continue; + } + $to = TransactionCurrency::where('code', $key)->first(); + if (null === $to) { + $validator->errors()->add(sprintf('rates.%s', $key), sprintf('Invalid currency code "%s".', $key)); + } + } + }); + } +} diff --git a/app/Api/V1/Requests/Models/CurrencyExchangeRate/UpdateRequest.php b/app/Api/V1/Requests/Models/CurrencyExchangeRate/UpdateRequest.php index ab37c34114..8114b7abed 100644 --- a/app/Api/V1/Requests/Models/CurrencyExchangeRate/UpdateRequest.php +++ b/app/Api/V1/Requests/Models/CurrencyExchangeRate/UpdateRequest.php @@ -52,6 +52,8 @@ class UpdateRequest extends FormRequest return [ 'date' => 'date|after:1970-01-02|before:2038-01-17', 'rate' => 'required|numeric|gt:0', + 'from' => 'nullable|exists:transaction_currencies,code', + 'to' => 'nullable|exists:transaction_currencies,code', ]; } } diff --git a/app/Repositories/ExchangeRate/ExchangeRateRepository.php b/app/Repositories/ExchangeRate/ExchangeRateRepository.php index f7936e0979..bcaf547cf8 100644 --- a/app/Repositories/ExchangeRate/ExchangeRateRepository.php +++ b/app/Repositories/ExchangeRate/ExchangeRateRepository.php @@ -55,20 +55,17 @@ class ExchangeRateRepository implements ExchangeRateRepositoryInterface, UserGro // orderBy('date', 'DESC')->toRawSql(); return $this->userGroup->currencyExchangeRates() - ->where(function (Builder $q1) use ($from, $to): void { - $q1->where(function (Builder $q) use ($from, $to): void { - $q->where('from_currency_id', $from->id) - ->where('to_currency_id', $to->id) - ; - })->orWhere(function (Builder $q) use ($from, $to): void { - $q->where('from_currency_id', $to->id) - ->where('to_currency_id', $from->id) - ; - }); - }) - ->orderBy('date', 'DESC') - ->get(['currency_exchange_rates.*']) - ; + ->where(function (Builder $q1) use ($from, $to): void { + $q1->where(function (Builder $q) use ($from, $to): void { + $q->where('from_currency_id', $from->id) + ->where('to_currency_id', $to->id); + })->orWhere(function (Builder $q) use ($from, $to): void { + $q->where('from_currency_id', $to->id) + ->where('to_currency_id', $from->id); + }); + }) + ->orderBy('date', 'DESC') + ->get(['currency_exchange_rates.*']); } @@ -78,11 +75,10 @@ class ExchangeRateRepository implements ExchangeRateRepositoryInterface, UserGro /** @var null|CurrencyExchangeRate */ return $this->userGroup->currencyExchangeRates() - ->where('from_currency_id', $from->id) - ->where('to_currency_id', $to->id) - ->where('date', $date->format('Y-m-d')) - ->first() - ; + ->where('from_currency_id', $from->id) + ->where('to_currency_id', $to->id) + ->where('date', $date->format('Y-m-d')) + ->first(); } #[Override] @@ -112,4 +108,12 @@ class ExchangeRateRepository implements ExchangeRateRepositoryInterface, UserGro return $object; } + + public function deleteRates(TransactionCurrency $from, TransactionCurrency $to): void + { + $this->userGroup->currencyExchangeRates() + ->where('from_currency_id', $from->id) + ->where('to_currency_id', $to->id) + ->delete(); + } } diff --git a/app/Repositories/ExchangeRate/ExchangeRateRepositoryInterface.php b/app/Repositories/ExchangeRate/ExchangeRateRepositoryInterface.php index 4d2d024e01..956c6a0787 100644 --- a/app/Repositories/ExchangeRate/ExchangeRateRepositoryInterface.php +++ b/app/Repositories/ExchangeRate/ExchangeRateRepositoryInterface.php @@ -46,6 +46,7 @@ use Illuminate\Support\Collection; interface ExchangeRateRepositoryInterface { public function deleteRate(CurrencyExchangeRate $rate): void; + public function deleteRates(TransactionCurrency $from, TransactionCurrency $to): void; public function getAll(): Collection; diff --git a/app/Transformers/ExchangeRateTransformer.php b/app/Transformers/ExchangeRateTransformer.php index 6303fb0184..18566cc69a 100644 --- a/app/Transformers/ExchangeRateTransformer.php +++ b/app/Transformers/ExchangeRateTransformer.php @@ -48,11 +48,13 @@ class ExchangeRateTransformer extends AbstractTransformer 'updated_at' => $rate->updated_at->toAtomString(), 'from_currency_id' => (string) $rate->fromCurrency->id, + 'from_currency_name' => $rate->fromCurrency->name, 'from_currency_code' => $rate->fromCurrency->code, 'from_currency_symbol' => $rate->fromCurrency->symbol, 'from_currency_decimal_places' => $rate->fromCurrency->decimal_places, 'to_currency_id' => (string) $rate->toCurrency->id, + 'to_currency_name' => $rate->toCurrency->name, 'to_currency_code' => $rate->toCurrency->code, 'to_currency_symbol' => $rate->toCurrency->symbol, 'to_currency_decimal_places' => $rate->toCurrency->decimal_places, diff --git a/routes/api.php b/routes/api.php index 2d346dce5b..09a72d07f4 100644 --- a/routes/api.php +++ b/routes/api.php @@ -76,20 +76,20 @@ Route::group( // get all Route::get('', ['uses' => 'IndexController@index', 'as' => 'index']); // get list of rates - Route::get('rates/{fromCurrencyCode}/{toCurrencyCode}', ['uses' => 'ShowController@show', 'as' => 'show']); - // get single rate Route::get('{userGroupExchangeRate}', ['uses' => 'ShowController@showSingleById', 'as' => 'show.single']); - Route::get('rates/{fromCurrencyCode}/{toCurrencyCode}/{date}', ['uses' => 'ShowController@showSingleByDate', 'as' => 'show.by-date'])->where(['start_date' => DATEFORMAT]); + Route::get('{fromCurrencyCode}/{toCurrencyCode}', ['uses' => 'ShowController@show', 'as' => 'show']); + Route::get('{fromCurrencyCode}/{toCurrencyCode}/{date}', ['uses' => 'ShowController@showSingleByDate', 'as' => 'show.by-date'])->where(['start_date' => DATEFORMAT]); // delete all rates - Route::delete('rates/{fromCurrencyCode}/{toCurrencyCode}', ['uses' => 'DestroyController@destroy', 'as' => 'destroy']); + Route::delete('{fromCurrencyCode}/{toCurrencyCode}', ['uses' => 'DestroyController@destroy', 'as' => 'destroy']); // delete single rate Route::delete('{userGroupExchangeRate}', ['uses' => 'DestroyController@destroySingleById', 'as' => 'destroy.single']); - Route::delete('rates/{fromCurrencyCode}/{toCurrencyCode}/{date}', ['uses' => 'DestroyController@destroySingleByDate', 'as' => 'destroy.by-date'])->where(['start_date' => DATEFORMAT]); + Route::delete('{fromCurrencyCode}/{toCurrencyCode}/{date}', ['uses' => 'DestroyController@destroySingleByDate', 'as' => 'destroy.by-date'])->where(['start_date' => DATEFORMAT]); // update single Route::put('{userGroupExchangeRate}', ['uses' => 'UpdateController@updateById', 'as' => 'update']); - Route::put('rates/{fromCurrencyCode}/{toCurrencyCode}/{date}', ['uses' => 'UpdateController@updateByDate', 'as' => 'update.by-date'])->where(['start_date' => DATEFORMAT]); + Route::put('{fromCurrencyCode}/{toCurrencyCode}/{date}', ['uses' => 'UpdateController@updateByDate', 'as' => 'update.by-date'])->where(['start_date' => DATEFORMAT]); + // post new rate Route::post('', ['uses' => 'StoreController@store', 'as' => 'store']); Route::post('by-date/{date}', ['uses' => 'StoreController@storeByDate', 'as' => 'store.by-date'])->where(['start_date' => DATEFORMAT]);