import 'dart:convert'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart'; import 'package:logger/logger.dart'; import 'package:seshat/config/constants.dart'; import 'package:seshat/domain/models/accounting.dart'; import 'package:seshat/domain/models/bal.dart'; import 'package:seshat/domain/models/bal_stats.dart'; import 'package:seshat/domain/models/book.dart'; import 'package:seshat/domain/models/book_instance.dart'; import 'package:seshat/domain/models/enums.dart'; import 'package:seshat/domain/models/owner.dart'; import 'package:seshat/domain/models/search_result.dart'; import 'package:seshat/utils/result.dart'; extension StringExtension on String { String capitalize() { return "${this[0].toUpperCase()}${this.substring(1).toLowerCase()}"; } } /// API Client to manage all authenticated REST routes class ApiClient { ApiClient(); /// JWT for registration String? token; /// Readiness of the API Client bool isReady = false; /// Storage to access JWT FlutterSecureStorage? _secureStorage; Logger log = Logger( printer: PrettyPrinter( colors: true, lineLength: 100, methodCount: 0, dateTimeFormat: DateTimeFormat.dateAndTime, ), ); /// Initializes connection to the [_secureStorage] Future _initStore() async { _secureStorage ??= const FlutterSecureStorage(); } /// Generates authorization headers and option [additionalHeaders] Future> _getHeaders([ Map? additionalHeaders, ]) async { await _initStore(); final token = await _secureStorage!.read(key: "token"); final headers = {"Authorization": "Bearer $token", ...?additionalHeaders}; return headers; } /* * ======================== * =====< Accounting >===== * ======================== */ /// Gets data about a BAL's needed returns Future> getAccounting(int balId) async { final url = "https://$apiBasePath/bal/$balId/accounting"; log.i("Fetching: getAccounting ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(Accounting.fromJSON(json)); case 403: throw "You don't own the specified BAL"; case 404: throw "No BAL with this is exists in database"; default: throw "Unknown error of code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Notifies the server that either Books, Money or All has been returned to the user /// [type] is the stringified version of [ReturnType] Future> returnToId(int balId, int ownerId, String type) async { final url = "https://$apiBasePath/bal/${balId.toString()}/accounting/return/${ownerId.toString()}"; log.i("Fetching: returnToId ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = jsonEncode({"return_type": type.capitalize()}); final response = await client.post( Uri.parse(url), headers: headers, body: body, ); switch (response.statusCode) { case 200: return Result.ok(response); case 403: throw "You don't own the specified BAL or owner"; case 404: throw "No BAL or owner with this id exists in database"; case 409: throw "Books and money have already been returned, or there was nothing to return"; default: throw "Unknown error of code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /* * ================= * =====[ BAL ]===== * ================= */ /// Get stats about a BAL's performance Future> getBalStats(int balId) async { final url = "https://$apiBasePath/bal/${balId.toString()}/stats"; log.i("Fetching: getBalStats ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(BalStats.fromJSON(json)); case 403: throw "You don't own the specified BAL"; case 404: throw "No BAL with this id exists in the database"; case 409: throw "The specified BAL is not ended yet"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Stops a BAL, putting it's [BalState] to [BalState.ended] Future> stopBal(int balId) async { final url = "https://$apiBasePath/bal/${balId.toString()}/stop"; log.i("Fetching: stopBal ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.post(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(Bal.fromJSON(json)); case 403: throw "You don't own the specified BAL"; case 404: throw "No BAL with specified ID found"; case 409: throw "Selected BAL was not on ongoing state"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Starts a BAL, putting it's [BalState] to [BalState.ongoing] Future> startBal(int balId) async { final url = "https://$apiBasePath/bal/${balId.toString()}/start"; log.i("Fetching: startBal ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.post(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(Bal.fromJSON(json)); case 403: throw "You don't own the specified BAL"; case 404: throw "No BAL with specified ID found"; case 409: throw "Cannot have multiple BAl ongoing at the same time!"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Changes the information about a [Bal], such as its [name], [startTime] or [endTime] Future> editBal( int balId, String name, DateTime startTime, DateTime endTime, ) async { final url = "https://$apiBasePath/bal/${balId.toString()}"; log.i("Fetching: editBal ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = { "name": name, "start_timestamp": (startTime.millisecondsSinceEpoch / 1000).round(), "end_timestamp": (endTime.millisecondsSinceEpoch / 1000).round(), }; final response = await client.patch( Uri.parse(url), headers: headers, body: jsonEncode(body), ); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(Bal.fromJSON(json)); case 403: throw "You don't own the specified BAL"; case 404: throw "No bal with specified id"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Gets a [Bal] from it's [balId] Future> getBalById(int balId) async { final url = "https://$apiBasePath/bal/${balId.toString()}"; log.i("Fetching: getBalById ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(Bal.fromJSON(json)); case 403: throw "You don't own the specified BAL"; case 404: throw "No BAL with this id found in database"; default: throw "Unknown error"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Adds a [Bal] from it's [name], [startTime] and [endTime] Future> addBal( String name, DateTime startTime, DateTime endTime, ) async { final url = "https://$apiBasePath/bal"; log.i("Fetching: addBal ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = { "name": name, "start_timestamp": (startTime.millisecondsSinceEpoch / 1000).round(), "end_timestamp": (endTime.millisecondsSinceEpoch / 1000).round(), }; final response = await client.post( Uri.parse(url), headers: headers, body: jsonEncode(body), ); switch (response.statusCode) { case 201: final json = jsonDecode(response.body); return Result.ok(Bal.fromJSON(json)); case 400: throw "Time cannot go backwards"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Gets a [List] of all [Bal] Future>> getBals() async { final url = "https://$apiBasePath/bals"; log.i("Fetching: getBals ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body) as List; return Result.ok( json.map((element) => Bal.fromJSON(element)).toList(), ); default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Gets the ongoing BAL for the user Future> getOngoingBal() async { final url = "https://$apiBasePath/bal/current"; log.i("Fetching: getCurrentBal ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); if (response.statusCode == 200) { final json = jsonDecode(response.body); return Result.ok(Bal.fromJSON(json)); } else if (response.statusCode == 404) { return Result.ok(null); } else { throw Exception("Something went wrong"); } } catch (e) { return Result.error(Exception(e)); } finally { client.close(); } } /* * =================== * =====[ BOOKS ]===== * =================== */ /// Gets a [Book] by its [bookId] Future> getBookById(int bookId) async { final url = "https://$apiBasePath/book/id/${bookId.toString()}"; log.i("Fetching: getBookById ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(Book.fromJSON(json)); case 404: throw "No book with this id exists in database"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); } } /// Gets a [Book] from its [ean] Future> getBookByEAN(String ean) async { final url = "https://$apiBasePath/book/ean/$ean"; log.i("Fetching: getBookByEan ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(Book.fromJSON(json)); case 404: throw "No book with this EAN found in the database of BNF"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); } } /* * ============================= * =====[ BOOKS INSTANCES ]===== * ============================= */ /// Gets a [BookInstance] from it's [title], [author] or both Future>> getBookInstanceBySearch( int balId, String title, String author, ) async { final url = "https://$apiBasePath/bal/${balId.toString()}/search"; log.i("Fetching: getBookInstancesBySearch ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = jsonEncode({"title": title, "author": author}); final response = await client.post( Uri.parse(url), headers: headers, body: body, ); switch (response.statusCode) { case 200: final json = jsonDecode(response.body) as List; return Result.ok( json.map((el) => SearchResult.fromJSON(el)).toList(), ); case 403: throw "You do not own the BAL"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); } } /// Gets a [BookInstance] from it's [ean] Future>> getBookInstanceByEAN( int balId, int ean, ) async { final url = "https://$apiBasePath/bal/${balId.toString()}/ean/${ean.toString()}/book_instances"; log.i("Fetching: getBookInstancesByEan ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body) as List; return Result.ok( json.map((el) => BookInstance.fromJSON(el)).toList(), ); case 403: throw "You do not own the BAL"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); } } /// Notifies the server of the sell of multiple [BookInstance]. [books] is in the form of /// ```dart /// final books = {"aBookInstanceId": 6.0} // and its price /// ``` Future> sellBooks(Map books) async { final url = "https://$apiBasePath/book_instance/sell/bulk"; log.i("Fetching: sellBooks ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = jsonEncode(books); final response = await client.post( Uri.parse(url), headers: headers, body: body, ); switch (response.statusCode) { case 200: return Result.ok(response.statusCode); case 403: throw "You don't own one of the specified books"; case 404: throw "One of the books was not found"; case 409: throw "One of the books wasn't available"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Creates a new [BookInstance] from it's [book], it's [owner], it's [bal] and it's [price] Future> sendNewBookInstance( Book book, Owner owner, Bal bal, double price, ) async { final url = "https://$apiBasePath/book_instance"; log.i("Fetching: sendBook ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = jsonEncode({ "bal_id": bal.id, "book_id": book.id, "owner_id": owner.id, "price": price, }); final response = await client.post( Uri.parse(url), headers: headers, body: body, ); switch (response.statusCode) { case 201: final json = jsonDecode(response.body); return Result.ok(BookInstance.fromJSON(json)); case 403: throw "You don't own that book instance"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { return Result.error(Exception(e)); } finally { client.close(); } } /* * ==================== * =====[ OWNERS ]===== * ==================== */ /// Gets an [Owner] by it's [ownerId] Future> getOwnerById(int ownerId) async { final url = "https://$apiBasePath/owner/${ownerId.toString()}"; log.i("Fetching: getOwnerById ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(Owner.fromJSON(json)); case 403: throw "You do not own specified owner"; case 404: throw "No owner with this id exists"; default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception("API $e")); } finally { client.close(); } } /// Get the owner of the current user Future> getOwnerOfUser() async { final url = "https://$apiBasePath/owner/self"; log.i("Fetching: getSectionOwner ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body); return Result.ok(Owner.fromJSON(json)); default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } /// Get a [List] of all [Owner] Future>> getOwners() async { final url = "https://$apiBasePath/owners"; log.i("Fetching: getOwners ($url)"); final client = Client(); try { final headers = await _getHeaders(); final response = await client.get(Uri.parse(url), headers: headers); switch (response.statusCode) { case 200: final json = jsonDecode(response.body) as List; return Result.ok( json.map((element) => Owner.fromJSON(element)).toList(), ); default: throw "Unknown error with code ${response.statusCode.toString()}"; } } on Exception catch (error) { return Result.error(error); } finally { client.close(); } } /// Adds an [Owner] from its [firstName], [lastName] and [contact] Future> addOwner( String firstName, String lastName, String contact, ) async { final url = "https://$apiBasePath/owner"; log.i("Fetching: addOwner ($url)"); final client = Client(); try { final headers = await _getHeaders({"Content-Type": "application/json"}); final body = { "first_name": firstName, "last_name": lastName, "contact": contact, }; final response = await client.post( Uri.parse(url), headers: headers, body: jsonEncode(body), ); switch (response.statusCode) { case 201: final json = jsonDecode(response.body); return Result.ok(Owner.fromJSON(json)); default: throw "Unknown error with code ${response.statusCode.toString()}"; } } catch (e) { log.e(e.toString()); return Result.error(Exception(e)); } finally { client.close(); } } }