From 09698edfba348794aee52e28d55903941cc49bcf Mon Sep 17 00:00:00 2001 From: Vinzent Date: Wed, 24 Jul 2024 04:21:19 +0200 Subject: [PATCH] feat: Support MultipartRequest in functions invoke (#977) * feat: support MultipartRequest in functions invoke * fix: export MultipartFile * test: add tests for body encoding * fix: set correct headers --- .../lib/functions_client.dart | 2 +- .../functions_client/lib/src/constants.dart | 1 - .../lib/src/functions_client.dart | 55 +++++++++-- .../test/custom_http_client.dart | 19 +++- .../test/functions_dart_test.dart | 92 ++++++++++++++++++- 5 files changed, 153 insertions(+), 16 deletions(-) diff --git a/packages/functions_client/lib/functions_client.dart b/packages/functions_client/lib/functions_client.dart index 3c2be314..8f973089 100644 --- a/packages/functions_client/lib/functions_client.dart +++ b/packages/functions_client/lib/functions_client.dart @@ -1,6 +1,6 @@ library functions_client; -export 'package:http/http.dart' show ByteStream; +export 'package:http/http.dart' show ByteStream, MultipartFile; export 'src/functions_client.dart'; export 'src/types.dart'; diff --git a/packages/functions_client/lib/src/constants.dart b/packages/functions_client/lib/src/constants.dart index 8e92242d..a747a768 100644 --- a/packages/functions_client/lib/src/constants.dart +++ b/packages/functions_client/lib/src/constants.dart @@ -2,7 +2,6 @@ import 'package:functions_client/src/version.dart'; class Constants { static const defaultHeaders = { - 'Content-Type': 'application/json', 'X-Client-Info': 'functions-dart/$version', }; } diff --git a/packages/functions_client/lib/src/functions_client.dart b/packages/functions_client/lib/src/functions_client.dart index 99d7c7a5..e9c3b52d 100644 --- a/packages/functions_client/lib/src/functions_client.dart +++ b/packages/functions_client/lib/src/functions_client.dart @@ -1,8 +1,10 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:functions_client/src/constants.dart'; import 'package:functions_client/src/types.dart'; import 'package:http/http.dart' as http; +import 'package:http/http.dart' show MultipartRequest; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; class FunctionsClient { @@ -38,11 +40,17 @@ class FunctionsClient { /// Invokes a function /// - /// [functionName] - the name of the function to invoke + /// [functionName] is the name of the function to invoke /// - /// [headers]: object representing the headers to send with the request + /// [headers] to send with the request + /// + /// [body] of the request when [files] is null and can be of type String + /// or an Object that is encodable to JSON with `jsonEncode`. + /// If [files] is not null, [body] represents the fields of the + /// [MultipartRequest] and must be be of type `Map`. + /// + /// [files] to send in a `MultipartRequest`. [body] is used for the fields. /// - /// [body]: the body of the request /// /// ```dart /// // Call a standard function @@ -70,12 +78,11 @@ class FunctionsClient { Future invoke( String functionName, { Map? headers, - Map? body, + Object? body, + Iterable? files, Map? queryParameters, HttpMethod method = HttpMethod.post, }) async { - final bodyStr = body == null ? null : await _isolate.encode(body); - final uri = Uri.parse('$_url/$functionName') .replace(queryParameters: queryParameters); @@ -84,12 +91,44 @@ class FunctionsClient { if (headers != null) ...headers }; - final request = http.Request(method.name, uri); + if (body != null && + (headers == null || headers.containsKey("Content-Type") == false)) { + finalHeaders['Content-Type'] = switch (body) { + Uint8List() => 'application/octet-stream', + String() => 'text/plain', + _ => 'application/json', + }; + } + final http.BaseRequest request; + if (files != null) { + assert( + body == null || body is Map, + 'body must be of type Map', + ); + final fields = body as Map?; + + request = http.MultipartRequest(method.name, uri) + ..fields.addAll(fields ?? {}) + ..files.addAll(files); + } else { + final bodyRequest = http.Request(method.name, uri); + + final String? bodyStr; + if (body == null) { + bodyStr = null; + } else if (body is String) { + bodyStr = body; + } else { + bodyStr = await _isolate.encode(body); + } + if (bodyStr != null) bodyRequest.body = bodyStr; + request = bodyRequest; + } finalHeaders.forEach((key, value) { request.headers[key] = value; }); - if (bodyStr != null) request.body = bodyStr; + final response = await (_httpClient?.send(request) ?? request.send()); final responseType = (response.headers['Content-Type'] ?? response.headers['content-type'] ?? diff --git a/packages/functions_client/test/custom_http_client.dart b/packages/functions_client/test/custom_http_client.dart index 247d6095..45ec449f 100644 --- a/packages/functions_client/test/custom_http_client.dart +++ b/packages/functions_client/test/custom_http_client.dart @@ -13,8 +13,9 @@ class CustomHttpClient extends BaseClient { Future send(BaseRequest request) async { // Add request to receivedRequests list. receivedRequests = receivedRequests..add(request); + request.finalize(); - if (request.url.path.endsWith("function")) { + if (request.url.path.endsWith("error-function")) { //Return custom status code to check for usage of this client. return StreamedResponse( Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))), @@ -32,8 +33,22 @@ class CustomHttpClient extends BaseClient { "Content-Type": "text/event-stream", }); } else { + final Stream> stream; + if (request is MultipartRequest) { + stream = Stream.value( + utf8.encode(jsonEncode([ + for (final file in request.files) + { + "name": file.field, + "content": await file.finalize().bytesToString() + } + ])), + ); + } else { + stream = Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))); + } return StreamedResponse( - Stream.value(utf8.encode(jsonEncode({"key": "Hello World"}))), + stream, 200, request: request, headers: { diff --git a/packages/functions_client/test/functions_dart_test.dart b/packages/functions_client/test/functions_dart_test.dart index 7637b8f0..4ef7b5c5 100644 --- a/packages/functions_client/test/functions_dart_test.dart +++ b/packages/functions_client/test/functions_dart_test.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:functions_client/src/functions_client.dart'; import 'package:functions_client/src/types.dart'; +import 'package:http/http.dart'; import 'package:test/test.dart'; import 'package:yet_another_json_isolate/yet_another_json_isolate.dart'; @@ -19,7 +20,7 @@ void main() { }); test('function throws', () async { try { - await functionsCustomHttpClient.invoke('function'); + await functionsCustomHttpClient.invoke('error-function'); fail('should throw'); } on FunctionException catch (e) { expect(e.status, 420); @@ -27,14 +28,16 @@ void main() { }); test('function call', () async { - final res = await functionsCustomHttpClient.invoke('function1'); + final res = await functionsCustomHttpClient.invoke('function'); + expect( + customHttpClient.receivedRequests.last.headers["Content-Type"], null); expect(res.data, {'key': 'Hello World'}); expect(res.status, 200); }); test('function call with query parameters', () async { final res = await functionsCustomHttpClient - .invoke('function1', queryParameters: {'key': 'value'}); + .invoke('function', queryParameters: {'key': 'value'}); final request = customHttpClient.receivedRequests.last; @@ -43,6 +46,27 @@ void main() { expect(res.status, 200); }); + test('function call with files', () async { + final fileName = "file.txt"; + final fileContent = "Hello World"; + final res = await functionsCustomHttpClient.invoke( + 'function', + queryParameters: {'key': 'value'}, + files: [ + MultipartFile.fromString(fileName, fileContent), + ], + ); + + final request = customHttpClient.receivedRequests.last; + + expect(request.url.queryParameters, {'key': 'value'}); + expect(request.headers['Content-Type'], contains('multipart/form-data')); + expect(res.data, [ + {'name': fileName, 'content': fileContent} + ]); + expect(res.status, 200); + }); + test('dispose isolate', () async { await functionsCustomHttpClient.dispose(); expect(functionsCustomHttpClient.invoke('function'), throwsStateError); @@ -57,7 +81,7 @@ void main() { ); await client.dispose(); - final res = await client.invoke('function1'); + final res = await client.invoke('function'); expect(res.data, {'key': 'Hello World'}); }); @@ -69,5 +93,65 @@ void main() { ['a', 'b', 'c'], )); }); + + group('body encoding', () { + test('integer properly encoded', () async { + await functionsCustomHttpClient.invoke('function', body: 42); + + final req = customHttpClient.receivedRequests.last; + expect(req, isA()); + + req as Request; + expect(req.body, '42'); + expect(req.headers["Content-Type"], contains("application/json")); + }); + + test('double is properly encoded', () async { + await functionsCustomHttpClient.invoke('function', body: 42.9); + + final req = customHttpClient.receivedRequests.last; + expect(req, isA()); + + req as Request; + expect(req.body, '42.9'); + expect(req.headers["Content-Type"], contains("application/json")); + }); + + test('string is properly encoded', () async { + await functionsCustomHttpClient.invoke('function', body: 'ExampleText'); + + final req = customHttpClient.receivedRequests.last; + expect(req, isA()); + + req as Request; + expect(req.body, 'ExampleText'); + expect(req.headers["Content-Type"], contains("text/plain")); + }); + + test('list is properly encoded', () async { + await functionsCustomHttpClient.invoke('function', body: [1, 2, 3]); + + final req = customHttpClient.receivedRequests.last; + expect(req, isA()); + + req as Request; + expect(req.body, '[1,2,3]'); + expect(req.headers["Content-Type"], contains("application/json")); + }); + + test('map is properly encoded', () async { + await functionsCustomHttpClient.invoke( + 'function', + body: {'thekey': 'thevalue'}, + ); + + final req = customHttpClient.receivedRequests.last; + expect(req, isA()); + + req as Request; + expect(req.body, '{"thekey":"thevalue"}'); + expect(req.headers["Content-Type"], contains("application/json")); + }); + }); }); }