From 6ed0acce2785312247e5a9743de97e5c25b5b722 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 16 Jul 2021 16:39:33 +1000 Subject: [PATCH] Refactor API requests - Return a non-nullable APIRequest object - Contains status-code, url, method, etc - Pass all API requests through a common function which does error handling --- lib/api.dart | 535 ++++++++++++++------------------------- lib/barcode.dart | 14 +- lib/inventree/model.dart | 41 +-- 3 files changed, 214 insertions(+), 376 deletions(-) diff --git a/lib/api.dart b/lib/api.dart index 787a3c52..04d4a42d 100644 --- a/lib/api.dart +++ b/lib/api.dart @@ -17,6 +17,27 @@ import 'package:inventree/l10.dart'; import 'package:http/http.dart' as http; + +/** + * Class representing an API response from the server + */ +class APIResponse { + + APIResponse({this.url = "", this.method = "", this.statusCode = -1, this.data}); + + int statusCode = -1; + + String url = ""; + + String method = ""; + + dynamic data; + + // Request is "valid" if a statusCode was returned + bool isValid() => statusCode >= 0; +} + + /** * Custom FileService for caching network images * Requires a custom badCertificateCallback, @@ -214,16 +235,12 @@ class InvenTreeAPI { print("Connecting to ${apiUrl} -> username=${username}"); - HttpClientResponse? response; + APIResponse response; - dynamic data; + response = await get("", expectedStatusCode: 200); - response = await getResponse(""); - - // Null response means something went horribly wrong! - // Most likely, the server cannot be contacted - if (response == null) { - // An error message has already been displayed! + // Response was invalid for some reason + if (!response.isValid()) { return false; } @@ -232,10 +249,8 @@ class InvenTreeAPI { return false; } - data = await responseToJson(response); - // We expect certain response from the server - if (data == null || !data.containsKey("server") || !data.containsKey("version") || !data.containsKey("instance")) { + if (response.data == null || !response.data.containsKey("server") || !response.data.containsKey("version") || !response.data.containsKey("instance")) { showServerError( L10().missingData, @@ -246,11 +261,11 @@ class InvenTreeAPI { } // Record server information - _version = data["version"]; - instance = data['instance'] ?? ''; + _version = response.data["version"]; + instance = response.data['instance'] ?? ''; // Default API version is 1 if not provided - _apiVersion = (data['apiVersion'] ?? 1) as int; + _apiVersion = (response.data['apiVersion'] ?? 1) as int; if (_apiVersion < _minApiVersion) { @@ -280,10 +295,10 @@ class InvenTreeAPI { print("Requesting token from server"); - response = await getResponse(_URL_GET_TOKEN); + response = await get(_URL_GET_TOKEN); - // A "null" response means that the request was unsuccessful - if (response == null) { + // Invalid response + if (!response.isValid()) { return false; } @@ -305,9 +320,7 @@ class InvenTreeAPI { return false; } - data = await responseToJson(response); - - if (data == null || !data.containsKey("token")) { + if (response.data == null || !response.data.containsKey("token")) { showServerError( L10().tokenMissing, L10().tokenMissingFromResponse, @@ -317,7 +330,7 @@ class InvenTreeAPI { } // Return the received token - _token = data["token"]; + _token = response.data["token"]; print("Received token - $_token"); // Request user role information @@ -383,17 +396,15 @@ class InvenTreeAPI { // Any 'older' version of the server allows any API method for any logged in user! // We will return immediately, but request the user roles in the background - var response = await get(_URL_GET_ROLES); + var response = await get(_URL_GET_ROLES, expectedStatusCode: 200); - // Null response from server - if (response == null) { - print("null response requesting user roles"); + if (!response.isValid() || response.statusCode != 200) { return; } - if (response.containsKey('roles')) { + if (response.data.containsKey('roles')) { // Save a local copy of the user roles - roles = response['roles']; + roles = response.data['roles']; } } @@ -420,107 +431,27 @@ class InvenTreeAPI { // Perform a PATCH request - Future patch(String url, {Map body = const {}, int expectedStatusCode=200}) async { - var _url = makeApiUrl(url); + Future patch(String url, {Map body = const {}, int expectedStatusCode=200}) async { var _body = Map(); // Copy across provided data body.forEach((K, V) => _body[K] = V); - print("PATCH: " + _url); + HttpClientRequest? request = await apiRequest(url, "PATCH"); - final uri = Uri.parse(_url); - - // Check for invalid host - if (uri.host.isEmpty) { - showServerError(L10().invalidHost, L10().invalidHostDetails); - return null; - } - - var client = createClient(true); - - HttpClientRequest? request; - - try { - // Open a connection to the server - request = await client.patchUrl(uri).timeout(Duration(seconds: 10)); - } on SocketException catch (error) { - showServerError(L10().connectionRefused, error.toString()); - return null; - } on TimeoutException { - showTimeoutError(); - return null; - } catch (error, stackTrace) { - showServerError( - L10().serverError, - error.toString() + if (request == null) { + // Return an "invalid" APIResponse + return new APIResponse( + url: url, + method: 'PATCH', ); - - sentryReportError(error, stackTrace); - - return null; } - var data = json.encode(_body); - - // Set headers - request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); - request.headers.set(HttpHeaders.acceptLanguageHeader, Intl.getCurrentLocale()); - request.headers.set(HttpHeaders.contentLengthHeader, data.length.toString()); - request.headers.set(HttpHeaders.authorizationHeader, _authorizationHeader()); - - request.add(utf8.encode(data)); - - HttpClientResponse? response; - - try { - response = await request.close().timeout(Duration(seconds: 10)); - } on SocketException catch (error) { - showServerError( - L10().connectionRefused, - error.toString() - ); - return null; - } on TimeoutException { - showTimeoutError(); - return null; - } catch (error, stackTrace) { - showServerError( - L10().serverError, - error.toString() - ); - - sentryReportError(error, stackTrace); - return null; - } - - var responseData = await responseToJson(response); - - if (response.statusCode != expectedStatusCode) { - showStatusCodeError(response.statusCode); - - print("PATCH to ${_url} returned status code ${response.statusCode}"); - print("Data:"); - print(responseData); - - // Server error - if (response.statusCode >= 500) { - sentryReportMessage( - "Server error on PATCH request", - context: { - "url": _url, - "statusCode": "${response.statusCode}", - "response": responseData.toString(), - "request": body.toString(), - } - ); - } - - return null; - } - - return responseData; + return completeRequest( + request, + data: json.encode(_body), + statusCode: expectedStatusCode + ); } /* @@ -571,155 +502,42 @@ class InvenTreeAPI { * We send this with the currently selected "locale", * so that (hopefully) the field messages are correctly translated */ - Future options(String url) async { + Future options(String url) async { - var _url = makeApiUrl(url); + HttpClientRequest? request = await apiRequest(url, "OPTIONS"); - var client = createClient(true); - - final uri = Uri.parse(_url); - - if (uri.host.isEmpty) { - showServerError(L10().invalidHost, L10().invalidHostDetails); - return null; + if (request == null) { + // Return an "invalid" APIResponse + return new APIResponse( + url: url, + method: 'OPTIONS' + ); } - HttpClientRequest? request; - HttpClientResponse? response; - - try { - request = await client.openUrl("OPTIONS", uri).timeout(Duration(seconds: 10)); - - request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - request.headers.set(HttpHeaders.acceptLanguageHeader, Intl.getCurrentLocale()); - request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); - request.headers.set(HttpHeaders.authorizationHeader, _authorizationHeader()); - - response = await request.close().timeout(Duration(seconds: 10)); - } on SocketException catch (error) { - showServerError(L10().connectionRefused, error.toString()); - return null; - } on TimeoutException { - showTimeoutError(); - return null; - } catch (error, stackTrace) { - showServerError(L10().serverError, error.toString()); - sentryReportError(error, stackTrace); - return null; - } - - var responseData = await responseToJson(response); - - return responseData; + return completeRequest(request); } /** * Perform a HTTP POST request * Returns a json object (or null if unsuccessful) */ - Future post(String url, {Map body = const {}, int expectedStatusCode=201}) async { + Future post(String url, {Map body = const {}, int expectedStatusCode=201}) async { - var _url = makeApiUrl(url); + HttpClientRequest? request = await apiRequest(url, "POST"); - print("POST: ${_url} -> ${body.toString()}"); - - var client = createClient(true); - - final uri = Uri.parse(_url); - - if (uri.host.isEmpty) { - showServerError(L10().invalidHost, L10().invalidHostDetails); - return null; + if (request == null) { + // Return an "invalid" APIResponse + return new APIResponse( + url: url, + method: 'POST' + ); } - HttpClientRequest? request; - - try { - // Open a connection to the server - request = await client.postUrl(uri).timeout(Duration(seconds: 10)); - } on SocketException catch (error) { - showServerError( - L10().connectionRefused, - error.toString() - ); - return null; - } on TimeoutException { - showTimeoutError(); - return null; - } catch (error, stackTrace) { - - showServerError( - L10().serverError, - error.toString() - ); - - sentryReportError(error, stackTrace); - - return null; - } - - var data = json.encode(body); - - // Set headers - // Ref: https://stackoverflow.com/questions/59713003/body-not-sending-using-map-in-flutter - request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); - request.headers.set(HttpHeaders.acceptLanguageHeader, Intl.getCurrentLocale()); - request.headers.set(HttpHeaders.contentLengthHeader, data.length.toString()); - request.headers.set(HttpHeaders.authorizationHeader, _authorizationHeader()); - - // Add JSON data to the request - request.add(utf8.encode(data)); - - HttpClientResponse? response; - - try { - response = await request.close().timeout(Duration(seconds: 10)); - } on SocketException catch (error) { - showServerError( - L10().connectionRefused, - error.toString() - ); - return null; - } on TimeoutException { - showTimeoutError(); - return null; - } catch (error, stackTrace) { - showServerError( - L10().serverError, - error.toString() - ); - - sentryReportError(error, stackTrace); - return null; - } - - var responseData = await responseToJson(response); - - if (response.statusCode != expectedStatusCode) { - showStatusCodeError(response.statusCode); - - print("POST to ${_url} returned status code ${response.statusCode}"); - print("Data:"); - print(responseData); - - // Server error - if (response.statusCode >= 500) { - sentryReportMessage( - "Server error on POST request", - context: { - "url": _url, - "statusCode": "${response.statusCode}", - "response": responseData.toString(), - "request": body.toString(), - } - ); - } - - return null; - } - - return responseData; + return completeRequest( + request, + data: json.encode(body), + statusCode: expectedStatusCode + ); } HttpClient createClient(bool allowBadCert) { @@ -749,20 +567,21 @@ class InvenTreeAPI { } /** - * Perform a HTTP GET request, - * and return the Response object - * (or null if the request fails) + * Initiate a HTTP request to the server + * + * @param url is the API endpoint + * @param method is the HTTP method e.g. 'POST' / 'PATCH' / 'GET' etc; + * @param params is the request parameters */ - Future getResponse(String url, {Map params = const {}}) async { + Future apiRequest(String url, String method, {Map urlParams = const {}}) async { + var _url = makeApiUrl(url); - print("GET: ${_url}"); + // Add any required query parameters to the URL using ?key=value notation + if (urlParams.isNotEmpty) { + String query = "?"; - // If query parameters are supplied, form a query string - if (params.isNotEmpty) { - String query = '?'; - - params.forEach((K, V) => query += K + '=' + V + '&'); + urlParams.forEach((k, v) => query += "${k}=${v}&"); _url += query; } @@ -772,70 +591,106 @@ class InvenTreeAPI { _url = _url.substring(0, _url.length - 1); } + Uri? _uri = Uri.tryParse(_url); + + print("apiRequest ${method} -> ${url}"); + + if (_uri == null) { + showServerError(L10().invalidHost, L10().invalidHostDetails); + return null; + } + + if (_uri.host.isEmpty) { + showServerError(L10().invalidHost, L10().invalidHostDetails); + return null; + } + + HttpClientRequest? _request; + var client = createClient(true); - Uri? uri = Uri.tryParse(_url); - - if (uri == null) { - showServerError(L10().invalidHost, L10().invalidHostDetails); - return null; - } - - // Check for invalid host - if (uri.host.isEmpty) { - showServerError(L10().invalidHost, L10().invalidHostDetails); - return null; - } - - HttpClientRequest? request; - + // Attempt to open a connection to the server try { - // Open a connection - request = await client.getUrl(uri).timeout(Duration(seconds: 10)); - } on TimeoutException { - showTimeoutError(); - return null; + _request = await client.openUrl(method, _uri).timeout(Duration(seconds: 10)); + + // Set headers + _request.headers.set(HttpHeaders.authorizationHeader, _authorizationHeader()); + _request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + _request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); + _request.headers.set(HttpHeaders.acceptLanguageHeader, Intl.getCurrentLocale()); + + return _request; } on SocketException catch (error) { showServerError(L10().connectionRefused, error.toString()); return null; - } on FormatException { - showServerError(L10().invalidHost, L10().invalidHostDetails); - return null; - } catch (error, stackTrace) { - sentryReportError(error, stackTrace); - showServerError(L10().serverError, error.toString()); - return null; - } - - // Set connection headers - request.headers.set(HttpHeaders.acceptHeader, 'application/json'); - request.headers.set(HttpHeaders.contentTypeHeader, 'application/json'); - request.headers.set(HttpHeaders.acceptLanguageHeader, Intl.getCurrentLocale()); - request.headers.set(HttpHeaders.authorizationHeader, _authorizationHeader()); - - try { - HttpClientResponse response = await request.close().timeout(Duration(seconds: 10)); - return response; - } on TimeoutException { showTimeoutError(); return null; - } on SocketException catch (error) { - showServerError(L10().connectionRefused, error.toString()); - return null; } catch (error, stackTrace) { - - showServerError( - L10().serverError, - error.toString() - ); - - sentryReportError(error, stackTrace); - + showServerError(L10().serverError, error.toString()); + sentryReportError(error, stackTrace); return null; } } + + /** + * Complete an API request, and return an APIResponse object + */ + Future completeRequest(HttpClientRequest request, {String? data, int? statusCode}) async { + + if (data != null && data.isNotEmpty) { + request.headers.set(HttpHeaders.contentLengthHeader, data.length.toString()); + request.add(utf8.encode(data)); + } + + APIResponse response = new APIResponse( + method: request.method, + url: request.uri.toString() + ); + + try { + HttpClientResponse? _response = await request.close().timeout(Duration(seconds: 10)); + + response.statusCode = _response.statusCode; + response.data = await responseToJson(_response); + + // Expected status code not returned + if ((statusCode != null) && (statusCode != _response.statusCode)) { + showStatusCodeError(_response.statusCode); + } + + // Report any server errors + if (_response.statusCode >= 500) { + sentryReportMessage( + "Server error", + context: { + "url": request.uri.toString(), + "method": request.method, + "statusCode": _response.statusCode.toString(), + "requestHeaders": request.headers.toString(), + "responseHeaders": _response.headers.toString(), + "responseData": response.data.toString(), + } + ); + } + + } on SocketException catch (error) { + showServerError(L10().connectionRefused, error.toString()); + } on TimeoutException { + showTimeoutError(); + } catch (error, stackTrace) { + showServerError(L10().serverError, error.toString()); + sentryReportError(error, stackTrace); + } + + return response; + + } + + /** + * Convert a HttpClientResponse response object to JSON + */ dynamic responseToJson(HttpClientResponse response) async { String body = await response.transform(utf8.decoder).join(); @@ -871,55 +726,37 @@ class InvenTreeAPI { * Perform a HTTP GET request * Returns a json object (or null if did not complete) */ - Future get(String url, {Map params = const {}, int expectedStatusCode=200}) async { + Future get(String url, {Map params = const {}, int expectedStatusCode=200}) async { - var response = await getResponse(url, params: params); + HttpClientRequest? request = await apiRequest( + url, + "GET", + urlParams: params, + ); - // A null response means something has gone wrong... - if (response == null) { - print("null response from GET ${url}"); - return null; + if (request == null) { + // Return an "invalid" APIResponse + return new APIResponse( + url: url, + method: 'GET', + ); } - var responseData = await responseToJson(response); - - // Check the status code of the response - if (response.statusCode != expectedStatusCode) { - showStatusCodeError(response.statusCode); - - // Server error - if (response.statusCode >= 500) { - sentryReportMessage( - "Server error on GET request", - context: { - "url": url, - "statusCode": "${response.statusCode}", - "response": responseData.toString(), - "params": params.toString(), - } - ); - } - - return null; - } - - return responseData; + return completeRequest(request); } + // Return a list of request headers Map defaultHeaders() { var headers = Map(); headers[HttpHeaders.authorizationHeader] = _authorizationHeader(); + headers[HttpHeaders.acceptHeader] = 'application/json'; + headers[HttpHeaders.contentTypeHeader] = 'application/json'; + headers[HttpHeaders.acceptLanguageHeader] = Intl.getCurrentLocale(); return headers; } - Map jsonHeaders() { - var headers = defaultHeaders(); - headers['Content-Type'] = 'application/json'; - return headers; - } - String _authorizationHeader() { if (_token.isNotEmpty) { return "Token $_token"; diff --git a/lib/barcode.dart b/lib/barcode.dart index 37fccd33..7f361e9b 100644 --- a/lib/barcode.dart +++ b/lib/barcode.dart @@ -92,7 +92,7 @@ class BarcodeHandler { print("Scanned barcode data: ${barcode}"); - var data = await InvenTreeAPI().post( + var response = await InvenTreeAPI().post( url, body: { "barcode": barcode, @@ -100,19 +100,19 @@ class BarcodeHandler { expectedStatusCode: 200 ); - if (data == null) { + if (!response.isValid()) { return; } - if (data.containsKey('error')) { + if (response.data.containsKey('error')) { _controller?.resumeCamera(); - onBarcodeUnknown(data); - } else if (data.containsKey('success')) { + onBarcodeUnknown(response.data); + } else if (response.data.containsKey('success')) { _controller?.resumeCamera(); - onBarcodeMatched(data); + onBarcodeMatched(response.data); } else { _controller?.resumeCamera(); - onBarcodeUnhandled(data); + onBarcodeUnhandled(response.data); } } } diff --git a/lib/inventree/model.dart b/lib/inventree/model.dart index 7c59a15d..a891636a 100644 --- a/lib/inventree/model.dart +++ b/lib/inventree/model.dart @@ -150,13 +150,13 @@ class InvenTreeModel { */ Future reload() async { - var response = await api.get(url, params: defaultGetFilters()); + var response = await api.get(url, params: defaultGetFilters(), expectedStatusCode: 200); - if (response == null) { + if (!response.isValid()) { return false; } - jsondata = response; + jsondata = response.data; return true; } @@ -164,19 +164,21 @@ class InvenTreeModel { // POST data to update the model Future update({Map values = const {}}) async { - var addr = path.join(URL, pk.toString()); + var url = path.join(URL, pk.toString()); - if (!addr.endsWith("/")) { - addr += "/"; + if (!url.endsWith("/")) { + url += "/"; } var response = await api.patch( - addr, + url, body: values, expectedStatusCode: 200 ); - if (response == null) return false; + if (!response.isValid()) { + return false; + } return true; } @@ -200,15 +202,13 @@ class InvenTreeModel { params[key] = filters[key] ?? ''; } - print("GET: $url ${params.toString()}"); - var response = await api.get(url, params: params); - if (response == null) { + if (!response.isValid()) { return null; } - return createFromJson(response); + return createFromJson(response.data); } Future create(Map data) async { @@ -225,11 +225,12 @@ class InvenTreeModel { var response = await api.post(URL, body: data); - if (response == null) { + // Invalid response returned from server + if (!response.isValid()) { return null; } - return createFromJson(response); + return createFromJson(response.data); } Future listPaginated(int limit, int offset, {Map filters = const {}}) async { @@ -244,19 +245,19 @@ class InvenTreeModel { var response = await api.get(URL, params: params); - if (response == null) { + if (!response.isValid()) { return null; } // Construct the response InvenTreePageResponse page = new InvenTreePageResponse(); - if (response.containsKey("count") && response.containsKey("results")) { - page.count = response["count"] as int; + if (response.data.containsKey("count") && response.data.containsKey("results")) { + page.count = response.data["count"] as int; page.results = []; - for (var result in response["results"]) { + for (var result in response.data["results"]) { page.addResult(createFromJson(result)); } @@ -283,7 +284,7 @@ class InvenTreeModel { // A list of "InvenTreeModel" items List results = []; - if (response == null) { + if (!response.isValid()) { return results; } @@ -291,7 +292,7 @@ class InvenTreeModel { // - No data receieved // - Data is not a list of maps - for (var d in response) { + for (var d in response.data) { // Create a new object (of the current class type InvenTreeModel obj = createFromJson(d);