From c629e9cc4f43774cc52bb3e919b1c5b3d8fac1f5 Mon Sep 17 00:00:00 2001 From: Vinzent Date: Sat, 20 Jul 2024 19:26:00 +0200 Subject: [PATCH 1/4] feat: support MultipartRequest in functions invoke --- .../lib/src/functions_client.dart | 46 +++++++++++++++---- .../test/custom_http_client.dart | 16 ++++++- .../test/functions_dart_test.dart | 21 +++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/packages/functions_client/lib/src/functions_client.dart b/packages/functions_client/lib/src/functions_client.dart index 99d7c7a5..967eac3d 100644 --- a/packages/functions_client/lib/src/functions_client.dart +++ b/packages/functions_client/lib/src/functions_client.dart @@ -3,6 +3,7 @@ import 'dart:convert'; 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 +39,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 +77,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 +90,36 @@ class FunctionsClient { if (headers != null) ...headers }; - final request = http.Request(method.name, uri); + 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..02d075f1 100644 --- a/packages/functions_client/test/custom_http_client.dart +++ b/packages/functions_client/test/custom_http_client.dart @@ -32,8 +32,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..0ea98b50 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'; @@ -43,6 +44,26 @@ 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( + 'function1', + queryParameters: {'key': 'value'}, + files: [ + MultipartFile.fromString(fileName, fileContent), + ], + ); + + final request = customHttpClient.receivedRequests.last; + + expect(request.url.queryParameters, {'key': 'value'}); + expect(res.data, [ + {'name': fileName, 'content': fileContent} + ]); + expect(res.status, 200); + }); + test('dispose isolate', () async { await functionsCustomHttpClient.dispose(); expect(functionsCustomHttpClient.invoke('function'), throwsStateError); From bff8e2a4c17c49a17a4be8dcc4049cf8190a0163 Mon Sep 17 00:00:00 2001 From: Vinzent Date: Tue, 23 Jul 2024 22:04:46 +0200 Subject: [PATCH 2/4] fix: export MultipartFile --- packages/functions_client/lib/functions_client.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'; From 028c4944c851d11015c96553f59fb6ec7d3f6f29 Mon Sep 17 00:00:00 2001 From: Vinzent Date: Tue, 23 Jul 2024 22:32:40 +0200 Subject: [PATCH 3/4] test: add tests for body encoding --- .../test/custom_http_client.dart | 2 +- .../test/functions_dart_test.dart | 65 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/functions_client/test/custom_http_client.dart b/packages/functions_client/test/custom_http_client.dart index 02d075f1..f4979044 100644 --- a/packages/functions_client/test/custom_http_client.dart +++ b/packages/functions_client/test/custom_http_client.dart @@ -14,7 +14,7 @@ class CustomHttpClient extends BaseClient { // Add request to receivedRequests list. receivedRequests = receivedRequests..add(request); - 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"}))), diff --git a/packages/functions_client/test/functions_dart_test.dart b/packages/functions_client/test/functions_dart_test.dart index 0ea98b50..92f0e8df 100644 --- a/packages/functions_client/test/functions_dart_test.dart +++ b/packages/functions_client/test/functions_dart_test.dart @@ -20,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); @@ -28,14 +28,14 @@ void main() { }); test('function call', () async { - final res = await functionsCustomHttpClient.invoke('function1'); + final res = await functionsCustomHttpClient.invoke('function'); 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; @@ -48,7 +48,7 @@ void main() { final fileName = "file.txt"; final fileContent = "Hello World"; final res = await functionsCustomHttpClient.invoke( - 'function1', + 'function', queryParameters: {'key': 'value'}, files: [ MultipartFile.fromString(fileName, fileContent), @@ -78,7 +78,7 @@ void main() { ); await client.dispose(); - final res = await client.invoke('function1'); + final res = await client.invoke('function'); expect(res.data, {'key': 'Hello World'}); }); @@ -90,5 +90,60 @@ 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'); + }); + + 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'); + }); + + 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'); + }); + + 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]'); + }); + + 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"}'); + }); + }); }); } From ee5af18a5dc756961c68157e492942138f569437 Mon Sep 17 00:00:00 2001 From: Vinzent Date: Tue, 23 Jul 2024 23:22:06 +0200 Subject: [PATCH 4/4] fix: set correct headers --- packages/functions_client/lib/src/constants.dart | 1 - packages/functions_client/lib/src/functions_client.dart | 9 +++++++++ packages/functions_client/test/custom_http_client.dart | 1 + packages/functions_client/test/functions_dart_test.dart | 8 ++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) 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 967eac3d..e9c3b52d 100644 --- a/packages/functions_client/lib/src/functions_client.dart +++ b/packages/functions_client/lib/src/functions_client.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:functions_client/src/constants.dart'; import 'package:functions_client/src/types.dart'; @@ -90,6 +91,14 @@ class FunctionsClient { if (headers != null) ...headers }; + 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( diff --git a/packages/functions_client/test/custom_http_client.dart b/packages/functions_client/test/custom_http_client.dart index f4979044..45ec449f 100644 --- a/packages/functions_client/test/custom_http_client.dart +++ b/packages/functions_client/test/custom_http_client.dart @@ -13,6 +13,7 @@ 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("error-function")) { //Return custom status code to check for usage of this client. diff --git a/packages/functions_client/test/functions_dart_test.dart b/packages/functions_client/test/functions_dart_test.dart index 92f0e8df..4ef7b5c5 100644 --- a/packages/functions_client/test/functions_dart_test.dart +++ b/packages/functions_client/test/functions_dart_test.dart @@ -29,6 +29,8 @@ void main() { test('function call', () async { final res = await functionsCustomHttpClient.invoke('function'); + expect( + customHttpClient.receivedRequests.last.headers["Content-Type"], null); expect(res.data, {'key': 'Hello World'}); expect(res.status, 200); }); @@ -58,6 +60,7 @@ void main() { 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} ]); @@ -100,6 +103,7 @@ void main() { req as Request; expect(req.body, '42'); + expect(req.headers["Content-Type"], contains("application/json")); }); test('double is properly encoded', () async { @@ -110,6 +114,7 @@ void main() { req as Request; expect(req.body, '42.9'); + expect(req.headers["Content-Type"], contains("application/json")); }); test('string is properly encoded', () async { @@ -120,6 +125,7 @@ void main() { req as Request; expect(req.body, 'ExampleText'); + expect(req.headers["Content-Type"], contains("text/plain")); }); test('list is properly encoded', () async { @@ -130,6 +136,7 @@ void main() { req as Request; expect(req.body, '[1,2,3]'); + expect(req.headers["Content-Type"], contains("application/json")); }); test('map is properly encoded', () async { @@ -143,6 +150,7 @@ void main() { req as Request; expect(req.body, '{"thekey":"thevalue"}'); + expect(req.headers["Content-Type"], contains("application/json")); }); }); });