blob: 444a77f72e198455ed55fc830a661a8bf3461343 [file] [log] [blame]
// Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'multipart_media_uploader.dart';
import 'request_impl.dart';
import 'requests.dart' as client_requests;
import 'resumable_media_uploader.dart';
import 'utils.dart';
/// Base class for all API clients, offering generic methods for
/// HTTP Requests to the API
class ApiRequester {
final http.Client _httpClient;
final String _rootUrl;
final String _basePath;
final Map<String, String> _requestHeaders;
ApiRequester(
this._httpClient,
this._rootUrl,
this._basePath,
this._requestHeaders,
) : assert(_rootUrl.endsWith('/'));
/// Sends a HTTPRequest using [method] (usually GET or POST) to [requestUrl]
/// using the specified [queryParams]. Optionally include a
/// [body] and/or [uploadMedia] in the request.
///
/// If [uploadMedia] was specified [downloadOptions] must be
/// [client_requests.DownloadOptions.Metadata] or `null`.
///
/// If [downloadOptions] is [client_requests.DownloadOptions.Metadata] the
/// result will be decoded as JSON.
///
/// If [downloadOptions] is `null` the result will be a Future completing with
/// `null`.
///
/// Otherwise the result will be downloaded as a [client_requests.Media]
Future request(
String requestUrl,
String method, {
String? body,
Map<String, List<String>>? queryParams,
client_requests.Media? uploadMedia,
client_requests.UploadOptions? uploadOptions,
client_requests.DownloadOptions? downloadOptions =
client_requests.DownloadOptions.metadata,
}) async {
if (uploadMedia != null &&
downloadOptions != client_requests.DownloadOptions.metadata) {
throw ArgumentError(
'When uploading a [Media] you cannot download a '
'[Media] at the same time!',
);
}
client_requests.ByteRange? downloadRange;
if (downloadOptions is client_requests.PartialDownloadOptions &&
!downloadOptions.isFullDownload) {
downloadRange = downloadOptions.range;
}
queryParams = queryParams?.cast<String, List<String>>();
var response = await _request(requestUrl, method, body, queryParams,
uploadMedia, uploadOptions, downloadOptions, downloadRange);
response = await validateResponse(response);
if (downloadOptions == null) {
// If no download options are given, the response is of no interest
// and we will drain the stream.
return response.stream.drain();
} else if (downloadOptions == client_requests.DownloadOptions.metadata) {
// Downloading JSON Metadata
final stringStream = _decodeStreamAsText(response);
if (stringStream == null) {
throw client_requests.ApiRequestError(
'Unable to read response with content-type '
"${response.headers['content-type']}.",
);
}
final bodyString = await stringStream.join();
if (bodyString.isEmpty) return null;
return json.decode(bodyString);
}
// Downloading Media.
final contentType = response.headers['content-type'];
if (contentType == null) {
throw client_requests.ApiRequestError(
"No 'content-type' header in media response.");
}
int? contentLength;
if (response.headers['content-length'] != null) {
contentLength = int.tryParse(response.headers['content-length']!);
}
if (downloadRange != null) {
if (contentLength != downloadRange.length) {
throw client_requests.ApiRequestError(
'Content length of response does not match requested range length.',
);
}
if (!isWeb) {
// TODO(kevmoo) on the web, should check access-control-expose-headers
// but this is easy for now
final contentRange = response.headers[_contentRangeHeader];
final expected = 'bytes ${downloadRange.start}-${downloadRange.end}/';
if (contentRange == null || !contentRange.startsWith(expected)) {
throw client_requests.ApiRequestError(
'Attempting partial '
"download but got invalid '$_contentRangeHeader' header "
'(was: $contentRange, expected: $expected).',
);
}
}
}
return client_requests.Media(response.stream, contentLength,
contentType: contentType);
}
Future<http.StreamedResponse> _request(
String requestUrl,
String method,
String? body,
Map<String, List<String>>? queryParams,
client_requests.Media? uploadMedia,
client_requests.UploadOptions? uploadOptions,
client_requests.DownloadOptions? downloadOptions,
client_requests.ByteRange? downloadRange,
) {
final downloadAsMedia = downloadOptions != null &&
downloadOptions != client_requests.DownloadOptions.metadata;
queryParams ??= {};
if (uploadMedia != null) {
if (uploadOptions is client_requests.ResumableUploadOptions) {
queryParams['uploadType'] = const ['resumable'];
} else if (body == null) {
queryParams['uploadType'] = const ['media'];
} else {
queryParams['uploadType'] = const ['multipart'];
}
}
if (downloadAsMedia) {
queryParams['alt'] = const ['media'];
} else if (downloadOptions != null) {
queryParams['alt'] = const ['json'];
}
String path;
if (requestUrl.startsWith('/')) {
path = '$_rootUrl${requestUrl.substring(1)}';
} else {
path = '$_rootUrl$_basePath$requestUrl';
}
var containsQueryParameter = path.contains('?');
void addQueryParameter(String name, String value) {
name = escapeVariable(name);
value = escapeVariable(value);
if (containsQueryParameter) {
path = '$path&$name=$value';
} else {
path = '$path?$name=$value';
}
containsQueryParameter = true;
}
queryParams.forEach((String key, List<String> values) {
for (var value in values) {
addQueryParameter(key, value);
}
});
final uri = Uri.parse(path);
Future<http.StreamedResponse> simpleUpload() {
final bodyStream = uploadMedia!.stream;
final request = RequestImpl(
method,
uri,
stream: bodyStream,
headers: {
..._requestHeaders,
'content-type': uploadMedia.contentType,
'content-length': '${uploadMedia.length}'
},
);
return _httpClient.send(request);
}
Future<http.StreamedResponse> simpleRequest() {
var length = 0;
final bodyController = StreamController<List<int>>();
if (body != null) {
final bytes = utf8.encode(body);
bodyController.add(bytes);
length = bytes.length;
}
bodyController.close();
final headers = {
..._requestHeaders,
'content-type': contentTypeJsonUtf8,
'content-length': '$length',
if (downloadRange != null)
'range': 'bytes=${downloadRange.start}-${downloadRange.end}',
};
final request = RequestImpl(
method,
uri,
stream: bodyController.stream,
headers: headers,
);
return _httpClient.send(request);
}
if (uploadMedia != null) {
// Three upload types:
// 1. Resumable: Upload of data + metadata with multiple requests.
// 2. Simple: Upload of media.
// 3. Multipart: Upload of data + metadata.
if (uploadOptions is client_requests.ResumableUploadOptions) {
final helper = ResumableMediaUploader(
_httpClient,
uploadMedia,
body,
uri,
method,
uploadOptions,
_requestHeaders,
);
return helper.upload();
}
if (uploadMedia.length == null) {
throw ArgumentError(
'For non-resumable uploads you need to specify the length of the '
'media to upload.',
);
}
if (body == null) {
return simpleUpload();
} else {
final uploader = MultipartMediaUploader(
_httpClient,
uploadMedia,
body,
uri,
method,
_requestHeaders,
);
return uploader.upload();
}
}
return simpleRequest();
}
}
Future<http.StreamedResponse> validateResponse(
http.StreamedResponse response,
) async {
final statusCode = response.statusCode;
// TODO: We assume that status codes between [200..400] are OK.
// Can we assume this?
if (statusCode < 200 || statusCode >= 400) {
// Some error happened, try to decode the response and fetch the error.
final stringStream = _decodeStreamAsText(response);
if (stringStream != null) {
var jsonResponse = await stringStream.transform(json.decoder).first;
if (jsonResponse is List && jsonResponse.length == 1) {
jsonResponse = jsonResponse.first;
}
if (jsonResponse is Map && jsonResponse['error'] is Map) {
final error = jsonResponse['error'] as Map;
final codeValue = error['code'];
final message = error['message'] as String?;
final code =
codeValue is String ? int.tryParse(codeValue) : codeValue as int?;
var errors = <client_requests.ApiRequestErrorDetail>[];
if (error.containsKey('errors') && error['errors'] is List) {
errors = (error['errors'] as List)
.map((e) =>
client_requests.ApiRequestErrorDetail.fromJson(e as Map))
.toList();
}
throw client_requests.DetailedApiRequestError(code, message,
errors: errors, jsonResponse: jsonResponse as Map<String, dynamic>);
}
}
throw client_requests.DetailedApiRequestError(
statusCode, 'No error details. HTTP status was: $statusCode.');
}
return response;
}
Stream<String>? _decodeStreamAsText(http.StreamedResponse response) {
// TODO: Correctly handle the response content-types, using correct
// decoder.
// Currently we assume that the api endpoint is responding with json
// encoded in UTF8.
if (isJson(response.headers['content-type'])) {
return response.stream.transform(const Utf8Decoder(allowMalformed: true));
} else {
return null;
}
}
const _contentRangeHeader = 'content-range';