Skip to content

Commit

Permalink
feat: Support MultipartRequest in functions invoke (#977)
Browse files Browse the repository at this point in the history
* feat: support MultipartRequest in functions invoke

* fix: export MultipartFile

* test: add tests for body encoding

* fix: set correct headers
  • Loading branch information
Vinzent03 authored Jul 24, 2024
1 parent 22cd12d commit 09698ed
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 16 deletions.
2 changes: 1 addition & 1 deletion packages/functions_client/lib/functions_client.dart
Original file line number Diff line number Diff line change
@@ -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';
1 change: 0 additions & 1 deletion packages/functions_client/lib/src/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
}
55 changes: 47 additions & 8 deletions packages/functions_client/lib/src/functions_client.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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<String, String>`.
///
/// [files] to send in a `MultipartRequest`. [body] is used for the fields.
///
/// [body]: the body of the request
///
/// ```dart
/// // Call a standard function
Expand Down Expand Up @@ -70,12 +78,11 @@ class FunctionsClient {
Future<FunctionResponse> invoke(
String functionName, {
Map<String, String>? headers,
Map<String, dynamic>? body,
Object? body,
Iterable<http.MultipartFile>? files,
Map<String, dynamic>? queryParameters,
HttpMethod method = HttpMethod.post,
}) async {
final bodyStr = body == null ? null : await _isolate.encode(body);

final uri = Uri.parse('$_url/$functionName')
.replace(queryParameters: queryParameters);

Expand All @@ -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<String, String>,
'body must be of type Map',
);
final fields = body as Map<String, String>?;

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'] ??
Expand Down
19 changes: 17 additions & 2 deletions packages/functions_client/test/custom_http_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ class CustomHttpClient extends BaseClient {
Future<StreamedResponse> 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"}))),
Expand All @@ -32,8 +33,22 @@ class CustomHttpClient extends BaseClient {
"Content-Type": "text/event-stream",
});
} else {
final Stream<List<int>> 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: {
Expand Down
92 changes: 88 additions & 4 deletions packages/functions_client/test/functions_dart_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -19,22 +20,24 @@ 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);
}
});

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;

Expand All @@ -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);
Expand All @@ -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'});
});

Expand All @@ -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<Request>());

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<Request>());

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<Request>());

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<Request>());

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<Request>());

req as Request;
expect(req.body, '{"thekey":"thevalue"}');
expect(req.headers["Content-Type"], contains("application/json"));
});
});
});
}

0 comments on commit 09698ed

Please sign in to comment.