api_manager.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  1. // ignore_for_file: constant_identifier_names, depend_on_referenced_packages, prefer_final_fields
  2. import 'dart:async';
  3. import 'dart:convert';
  4. import 'dart:core';
  5. import 'dart:io';
  6. import 'dart:typed_data';
  7. import 'package:collection/collection.dart';
  8. import 'package:http/http.dart' as http;
  9. import 'package:equatable/equatable.dart';
  10. import 'package:http_parser/http_parser.dart';
  11. import 'package:mime_type/mime_type.dart';
  12. import 'package:flutter/foundation.dart';
  13. import 'package:http/browser_client.dart'
  14. if (dart.library.io) 'browser_client_stub.dart';
  15. import '/flutter_flow/uploaded_file.dart';
  16. import '/backend/api_requests/api_streaming.dart';
  17. import 'get_streamed_response.dart';
  18. enum ApiCallType {
  19. GET,
  20. POST,
  21. DELETE,
  22. PUT,
  23. PATCH,
  24. }
  25. enum BodyType {
  26. NONE,
  27. JSON,
  28. TEXT,
  29. X_WWW_FORM_URL_ENCODED,
  30. MULTIPART,
  31. }
  32. class ApiCallOptions extends Equatable {
  33. const ApiCallOptions({
  34. this.callName = '',
  35. required this.callType,
  36. required this.apiUrl,
  37. required this.headers,
  38. required this.params,
  39. this.bodyType,
  40. this.body,
  41. this.returnBody = true,
  42. this.encodeBodyUtf8 = false,
  43. this.decodeUtf8 = false,
  44. this.alwaysAllowBody = false,
  45. this.cache = false,
  46. this.isStreamingApi = false,
  47. });
  48. final String callName;
  49. final ApiCallType callType;
  50. final String apiUrl;
  51. final Map<String, dynamic> headers;
  52. final Map<String, dynamic> params;
  53. final BodyType? bodyType;
  54. final String? body;
  55. final bool returnBody;
  56. final bool encodeBodyUtf8;
  57. final bool decodeUtf8;
  58. final bool alwaysAllowBody;
  59. final bool cache;
  60. final bool isStreamingApi;
  61. /// Creates a new [ApiCallOptions] with optionally updated parameters.
  62. ///
  63. /// This helper function allows creating a copy of the current options while
  64. /// selectively modifying specific fields. Any parameter that is not provided
  65. /// will retain its original value from the current instance.
  66. ApiCallOptions copyWith({
  67. String? callName,
  68. ApiCallType? callType,
  69. String? apiUrl,
  70. Map<String, dynamic>? headers,
  71. Map<String, dynamic>? params,
  72. BodyType? bodyType,
  73. String? body,
  74. bool? returnBody,
  75. bool? encodeBodyUtf8,
  76. bool? decodeUtf8,
  77. bool? alwaysAllowBody,
  78. bool? cache,
  79. bool? isStreamingApi,
  80. }) {
  81. return ApiCallOptions(
  82. callName: callName ?? this.callName,
  83. callType: callType ?? this.callType,
  84. apiUrl: apiUrl ?? this.apiUrl,
  85. headers: headers ?? _cloneMap(this.headers),
  86. params: params ?? _cloneMap(this.params),
  87. bodyType: bodyType ?? this.bodyType,
  88. body: body ?? this.body,
  89. returnBody: returnBody ?? this.returnBody,
  90. encodeBodyUtf8: encodeBodyUtf8 ?? this.encodeBodyUtf8,
  91. decodeUtf8: decodeUtf8 ?? this.decodeUtf8,
  92. alwaysAllowBody: alwaysAllowBody ?? this.alwaysAllowBody,
  93. cache: cache ?? this.cache,
  94. isStreamingApi: isStreamingApi ?? this.isStreamingApi,
  95. );
  96. }
  97. ApiCallOptions clone() => ApiCallOptions(
  98. callName: callName,
  99. callType: callType,
  100. apiUrl: apiUrl,
  101. headers: _cloneMap(headers),
  102. params: _cloneMap(params),
  103. bodyType: bodyType,
  104. body: body,
  105. returnBody: returnBody,
  106. encodeBodyUtf8: encodeBodyUtf8,
  107. decodeUtf8: decodeUtf8,
  108. alwaysAllowBody: alwaysAllowBody,
  109. cache: cache,
  110. isStreamingApi: isStreamingApi,
  111. );
  112. @override
  113. List<Object?> get props => [
  114. callName,
  115. callType.name,
  116. apiUrl,
  117. headers,
  118. params,
  119. bodyType,
  120. body,
  121. returnBody,
  122. encodeBodyUtf8,
  123. decodeUtf8,
  124. alwaysAllowBody,
  125. cache,
  126. isStreamingApi,
  127. ];
  128. static Map<String, dynamic> _cloneMap(Map<String, dynamic> map) {
  129. try {
  130. return json.decode(json.encode(map)) as Map<String, dynamic>;
  131. } catch (_) {
  132. return Map.from(map);
  133. }
  134. }
  135. }
  136. class ApiCallResponse {
  137. const ApiCallResponse(
  138. this.jsonBody,
  139. this.headers,
  140. this.statusCode, {
  141. this.response,
  142. this.streamedResponse,
  143. this.exception,
  144. this.requestOptions,
  145. });
  146. final dynamic jsonBody;
  147. final Map<String, String> headers;
  148. final int statusCode;
  149. final http.Response? response;
  150. final http.StreamedResponse? streamedResponse;
  151. final Object? exception;
  152. /// The original request options used to make the API call.
  153. /// Available in interceptor's onResponse callback to access request details
  154. /// like URL, HTTP method, headers, params, and request body.
  155. final ApiCallOptions? requestOptions;
  156. // Whether we received a 2xx status (which generally marks success).
  157. bool get succeeded => statusCode >= 200 && statusCode < 300;
  158. String getHeader(String headerName) => headers[headerName] ?? '';
  159. // Return the raw body from the response, or if this came from a cloud call
  160. // and the body is not a string, then the json encoded body.
  161. String get bodyText =>
  162. response?.body ??
  163. (jsonBody is String ? jsonBody as String : jsonEncode(jsonBody));
  164. String get exceptionMessage => exception.toString();
  165. /// Creates a new [ApiCallResponse] with optionally updated parameters.
  166. ///
  167. /// This helper function allows creating a copy of the current response while
  168. /// selectively modifying specific fields. Any parameter that is not provided
  169. /// will retain its original value from the current instance.
  170. ApiCallResponse copyWith({
  171. dynamic jsonBody,
  172. Map<String, String>? headers,
  173. int? statusCode,
  174. http.Response? response,
  175. http.StreamedResponse? streamedResponse,
  176. Object? exception,
  177. ApiCallOptions? requestOptions,
  178. }) {
  179. return ApiCallResponse(
  180. jsonBody ?? this.jsonBody,
  181. headers ?? this.headers,
  182. statusCode ?? this.statusCode,
  183. response: response ?? this.response,
  184. streamedResponse: streamedResponse ?? this.streamedResponse,
  185. exception: exception ?? this.exception,
  186. requestOptions: requestOptions ?? this.requestOptions,
  187. );
  188. }
  189. static ApiCallResponse fromHttpResponse(
  190. http.Response response,
  191. bool returnBody,
  192. bool decodeUtf8,
  193. ) {
  194. dynamic jsonBody;
  195. try {
  196. final responseBody = decodeUtf8 && returnBody
  197. ? const Utf8Decoder().convert(response.bodyBytes)
  198. : response.body;
  199. jsonBody = returnBody ? json.decode(responseBody) : null;
  200. } catch (_) {}
  201. return ApiCallResponse(
  202. jsonBody,
  203. response.headers,
  204. response.statusCode,
  205. response: response,
  206. );
  207. }
  208. static ApiCallResponse fromCloudCallResponse(Map<String, dynamic> response) =>
  209. ApiCallResponse(
  210. response['body'],
  211. ApiManager.toStringMap(response['headers'] ?? {}),
  212. response['statusCode'] ?? 400,
  213. );
  214. }
  215. class ApiManager {
  216. ApiManager._();
  217. // Cache that will ensure identical calls are not repeatedly made.
  218. static Map<ApiCallOptions, ApiCallResponse> _apiCache = {};
  219. static ApiManager? _instance;
  220. static ApiManager get instance => _instance ??= ApiManager._();
  221. /// Get HTTP client with optional credentials support for web
  222. ///
  223. /// Parameters:
  224. /// - withCredentials: Whether to include credentials (cookies) with requests
  225. /// Only applies to web platform (BrowserClient)
  226. /// Default: false
  227. ///
  228. /// Returns a platform-specific HTTP client:
  229. /// - Web: BrowserClient with credentials setting applied
  230. /// - Mobile/Desktop: Standard http.Client
  231. static http.Client getClient({bool withCredentials = false}) {
  232. // For web platform, return BrowserClient with appropriate settings
  233. if (kIsWeb) {
  234. return BrowserClient()..withCredentials = withCredentials;
  235. }
  236. // For mobile/desktop, return standard http.Client
  237. // (credentials are handled differently on these platforms)
  238. return http.Client();
  239. }
  240. // If your API calls need authentication, populate this field once
  241. // the user has authenticated. Alter this as needed.
  242. static String? _accessToken;
  243. // Map of active streaming response subscriptions
  244. // Key is a unique identifier for the subscription
  245. // Value is the stream subscription
  246. final Map<String, StreamSubscription<ResponseStreamMessage>>
  247. _activeStreamingResponseSubscriptions = {};
  248. // Add a new active streaming response subscription
  249. void addActiveStreamingResponseSubscription(
  250. String subscriptionKey,
  251. StreamSubscription<ResponseStreamMessage>? subscription,
  252. ) {
  253. // Check if the subscription key is empty or if the subscription is null
  254. if (subscriptionKey.isEmpty || subscription == null) {
  255. return;
  256. }
  257. // Add the subscription to the map
  258. _activeStreamingResponseSubscriptions[subscriptionKey] = subscription;
  259. }
  260. // Cancel an active streaming response subscription
  261. Future<void> cancelActiveStreamingResponseSubscription(
  262. String subscriptionKey,
  263. ) async {
  264. // Check if the subscription key is in the map
  265. if (_activeStreamingResponseSubscriptions.containsKey(subscriptionKey)) {
  266. // Cancel the subscription
  267. await _activeStreamingResponseSubscriptions[subscriptionKey]!.cancel();
  268. }
  269. // Remove the subscription from the map
  270. _activeStreamingResponseSubscriptions.remove(subscriptionKey);
  271. }
  272. // You may want to call this if, for example, you make a change to the
  273. // database and no longer want the cached result of a call that may
  274. // have changed.
  275. static void clearCache(String callName) => _apiCache.keys
  276. .toSet()
  277. .forEach((k) => k.callName == callName ? _apiCache.remove(k) : null);
  278. static Map<String, String> toStringMap(Map map) =>
  279. map.map((key, value) => MapEntry(key.toString(), value.toString()));
  280. static String asQueryParams(Map<String, dynamic> map) => map.entries
  281. .map((e) =>
  282. "${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value.toString())}")
  283. .join('&');
  284. static Future<ApiCallResponse> urlRequest(
  285. ApiCallType callType,
  286. String apiUrl,
  287. Map<String, dynamic> headers,
  288. Map<String, dynamic> params,
  289. bool returnBody,
  290. bool decodeUtf8,
  291. bool isStreamingApi, {
  292. http.Client? client,
  293. }) async {
  294. if (params.isNotEmpty) {
  295. final specifier =
  296. Uri.parse(apiUrl).queryParameters.isNotEmpty ? '&' : '?';
  297. apiUrl = '$apiUrl$specifier${asQueryParams(params)}';
  298. }
  299. if (isStreamingApi) {
  300. client ??= http.Client();
  301. final request =
  302. http.Request(callType.toString().split('.').last, Uri.parse(apiUrl))
  303. ..headers.addAll(toStringMap(headers));
  304. final streamedResponse = await getStreamedResponse(request);
  305. return ApiCallResponse(
  306. null,
  307. streamedResponse.headers,
  308. streamedResponse.statusCode,
  309. streamedResponse: streamedResponse,
  310. );
  311. }
  312. final makeRequest = callType == ApiCallType.GET
  313. ? (client != null ? client.get : http.get)
  314. : (client != null ? client.delete : http.delete);
  315. final response =
  316. await makeRequest(Uri.parse(apiUrl), headers: toStringMap(headers));
  317. return ApiCallResponse.fromHttpResponse(response, returnBody, decodeUtf8);
  318. }
  319. static Future<ApiCallResponse> requestWithBody(
  320. ApiCallType type,
  321. String apiUrl,
  322. Map<String, dynamic> headers,
  323. Map<String, dynamic> params,
  324. String? body,
  325. BodyType? bodyType,
  326. bool returnBody,
  327. bool encodeBodyUtf8,
  328. bool decodeUtf8,
  329. bool alwaysAllowBody,
  330. bool isStreamingApi, {
  331. http.Client? client,
  332. }) async {
  333. assert(
  334. {ApiCallType.POST, ApiCallType.PUT, ApiCallType.PATCH}.contains(type) ||
  335. (alwaysAllowBody && type == ApiCallType.DELETE),
  336. 'Invalid ApiCallType $type for request with body',
  337. );
  338. final postBody =
  339. createBody(headers, params, body, bodyType, encodeBodyUtf8);
  340. if (isStreamingApi) {
  341. client ??= http.Client();
  342. final request =
  343. http.Request(type.toString().split('.').last, Uri.parse(apiUrl))
  344. ..headers.addAll(toStringMap(headers));
  345. request.body = postBody;
  346. final streamedResponse = await getStreamedResponse(request);
  347. return ApiCallResponse(
  348. null,
  349. streamedResponse.headers,
  350. streamedResponse.statusCode,
  351. streamedResponse: streamedResponse,
  352. );
  353. }
  354. if (bodyType == BodyType.MULTIPART) {
  355. return multipartRequest(type, apiUrl, headers, params, returnBody,
  356. decodeUtf8, alwaysAllowBody, client);
  357. }
  358. final requestFn = {
  359. ApiCallType.POST: client != null ? client.post : http.post,
  360. ApiCallType.PUT: client != null ? client.put : http.put,
  361. ApiCallType.PATCH: client != null ? client.patch : http.patch,
  362. ApiCallType.DELETE: client != null ? client.delete : http.delete,
  363. }[type]!;
  364. final response = await requestFn(Uri.parse(apiUrl),
  365. headers: toStringMap(headers), body: postBody);
  366. return ApiCallResponse.fromHttpResponse(response, returnBody, decodeUtf8);
  367. }
  368. static Future<ApiCallResponse> multipartRequest(
  369. ApiCallType? type,
  370. String apiUrl,
  371. Map<String, dynamic> headers,
  372. Map<String, dynamic> params,
  373. bool returnBody,
  374. bool decodeUtf8,
  375. bool alwaysAllowBody,
  376. http.Client? client,
  377. ) async {
  378. assert(
  379. {ApiCallType.POST, ApiCallType.PUT, ApiCallType.PATCH}.contains(type) ||
  380. (alwaysAllowBody && type == ApiCallType.DELETE),
  381. 'Invalid ApiCallType $type for request with body',
  382. );
  383. bool isFile(dynamic e) =>
  384. e is FFUploadedFile ||
  385. e is List<FFUploadedFile> ||
  386. (e is List && e.firstOrNull is FFUploadedFile);
  387. final nonFileParams = toStringMap(
  388. Map.fromEntries(params.entries.where((e) => !isFile(e.value))));
  389. List<http.MultipartFile> files = [];
  390. params.entries.where((e) => isFile(e.value)).forEach((e) {
  391. final param = e.value;
  392. final uploadedFiles = param is List
  393. ? param as List<FFUploadedFile>
  394. : [param as FFUploadedFile];
  395. for (var uploadedFile in uploadedFiles) {
  396. files.add(
  397. http.MultipartFile.fromBytes(
  398. e.key,
  399. uploadedFile.bytes ?? Uint8List.fromList([]),
  400. filename: uploadedFile.name,
  401. contentType: _getMediaType(uploadedFile.name),
  402. ),
  403. );
  404. }
  405. });
  406. final request = http.MultipartRequest(
  407. type.toString().split('.').last, Uri.parse(apiUrl))
  408. ..headers.addAll(toStringMap(headers))
  409. ..files.addAll(files);
  410. nonFileParams.forEach((key, value) => request.fields[key] = value);
  411. final response = await http.Response.fromStream(
  412. await (client != null ? client.send(request) : request.send()));
  413. return ApiCallResponse.fromHttpResponse(response, returnBody, decodeUtf8);
  414. }
  415. static MediaType? _getMediaType(String? filename) {
  416. final contentType = mime(filename);
  417. if (contentType == null) {
  418. return null;
  419. }
  420. final parts = contentType.split('/');
  421. if (parts.length != 2) {
  422. return null;
  423. }
  424. return MediaType(parts.first, parts.last);
  425. }
  426. static dynamic createBody(
  427. Map<String, dynamic> headers,
  428. Map<String, dynamic>? params,
  429. String? body,
  430. BodyType? bodyType,
  431. bool encodeBodyUtf8,
  432. ) {
  433. String? contentType;
  434. dynamic postBody;
  435. switch (bodyType) {
  436. case BodyType.JSON:
  437. contentType = 'application/json';
  438. postBody = body ?? json.encode(params ?? {});
  439. break;
  440. case BodyType.TEXT:
  441. contentType = 'text/plain';
  442. postBody = body ?? json.encode(params ?? {});
  443. break;
  444. case BodyType.X_WWW_FORM_URL_ENCODED:
  445. contentType = 'application/x-www-form-urlencoded';
  446. postBody = toStringMap(params ?? {});
  447. break;
  448. case BodyType.MULTIPART:
  449. contentType = 'multipart/form-data';
  450. postBody = params;
  451. break;
  452. case BodyType.NONE:
  453. case null:
  454. break;
  455. }
  456. // Set "Content-Type" header if it was previously unset.
  457. if (contentType != null &&
  458. !headers.keys.any((h) => h.toLowerCase() == 'content-type')) {
  459. headers['Content-Type'] = contentType;
  460. }
  461. return encodeBodyUtf8 && postBody is String
  462. ? utf8.encode(postBody)
  463. : postBody;
  464. }
  465. Future<ApiCallResponse> call(
  466. ApiCallOptions options, {
  467. http.Client? client,
  468. }) =>
  469. makeApiCall(
  470. callName: options.callName,
  471. apiUrl: options.apiUrl,
  472. callType: options.callType,
  473. headers: options.headers,
  474. params: options.params,
  475. body: options.body,
  476. bodyType: options.bodyType,
  477. returnBody: options.returnBody,
  478. encodeBodyUtf8: options.encodeBodyUtf8,
  479. decodeUtf8: options.decodeUtf8,
  480. alwaysAllowBody: options.alwaysAllowBody,
  481. cache: options.cache,
  482. isStreamingApi: options.isStreamingApi,
  483. options: options,
  484. client: client,
  485. );
  486. Future<ApiCallResponse> makeApiCall({
  487. required String callName,
  488. required String apiUrl,
  489. required ApiCallType callType,
  490. Map<String, dynamic> headers = const {},
  491. Map<String, dynamic> params = const {},
  492. String? body,
  493. BodyType? bodyType,
  494. bool returnBody = true,
  495. bool encodeBodyUtf8 = false,
  496. bool decodeUtf8 = false,
  497. bool alwaysAllowBody = false,
  498. bool cache = false,
  499. bool isStreamingApi = false,
  500. ApiCallOptions? options,
  501. http.Client? client,
  502. }) async {
  503. final callOptions = options ??
  504. ApiCallOptions(
  505. callName: callName,
  506. callType: callType,
  507. apiUrl: apiUrl,
  508. headers: headers,
  509. params: params,
  510. bodyType: bodyType,
  511. body: body,
  512. returnBody: returnBody,
  513. encodeBodyUtf8: encodeBodyUtf8,
  514. decodeUtf8: decodeUtf8,
  515. alwaysAllowBody: alwaysAllowBody,
  516. cache: cache,
  517. isStreamingApi: isStreamingApi,
  518. );
  519. // Modify for your specific needs if this differs from your API.
  520. if (_accessToken != null) {
  521. headers[HttpHeaders.authorizationHeader] = 'Bearer $_accessToken';
  522. }
  523. if (!apiUrl.startsWith('http')) {
  524. apiUrl = 'https://$apiUrl';
  525. }
  526. // If we've already made this exact call before and caching is on,
  527. // return the cached result.
  528. if (cache && _apiCache.containsKey(callOptions)) {
  529. return _apiCache[callOptions]!;
  530. }
  531. ApiCallResponse result;
  532. try {
  533. switch (callType) {
  534. case ApiCallType.GET:
  535. result = await urlRequest(
  536. callType,
  537. apiUrl,
  538. headers,
  539. params,
  540. returnBody,
  541. decodeUtf8,
  542. isStreamingApi,
  543. client: client,
  544. );
  545. break;
  546. case ApiCallType.DELETE:
  547. result = alwaysAllowBody
  548. ? await requestWithBody(
  549. callType,
  550. apiUrl,
  551. headers,
  552. params,
  553. body,
  554. bodyType,
  555. returnBody,
  556. encodeBodyUtf8,
  557. decodeUtf8,
  558. alwaysAllowBody,
  559. isStreamingApi,
  560. client: client,
  561. )
  562. : await urlRequest(
  563. callType,
  564. apiUrl,
  565. headers,
  566. params,
  567. returnBody,
  568. decodeUtf8,
  569. isStreamingApi,
  570. client: client,
  571. );
  572. break;
  573. case ApiCallType.POST:
  574. case ApiCallType.PUT:
  575. case ApiCallType.PATCH:
  576. result = await requestWithBody(
  577. callType,
  578. apiUrl,
  579. headers,
  580. params,
  581. body,
  582. bodyType,
  583. returnBody,
  584. encodeBodyUtf8,
  585. decodeUtf8,
  586. alwaysAllowBody,
  587. isStreamingApi,
  588. client: client,
  589. );
  590. break;
  591. }
  592. // If caching is on, cache the result (if present).
  593. if (cache) {
  594. _apiCache[callOptions] = result;
  595. }
  596. } catch (e) {
  597. result = ApiCallResponse(null, {}, -1, exception: e);
  598. }
  599. return result;
  600. }
  601. }