blob: b20e1781815d55a5ec053657c93109650ab41c95 [file] [log] [blame]
// Copyright 2019 The Flutter Authors. 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 'dart:io';
import 'dart:typed_data';
import 'package:meta/meta.dart';
import '../model/google/token_info.dart';
import 'authentication.dart';
import 'body.dart';
import 'exceptions.dart';
import 'request_handler.dart';
/// A [RequestHandler] that handles API requests.
///
/// API requests adhere to a specific contract, as follows:
///
/// * All requests must be authenticated per [AuthenticationProvider].
///
/// `T` is the type of object that is returned as the body of the HTTP response
/// (before serialization). Subclasses whose HTTP responses don't include a
/// body should extend `RequestHandler<Body>` and return null in their service
/// handlers ([get] and [post]).
@immutable
abstract class ApiRequestHandler<T extends Body> extends RequestHandler<T> {
/// Creates a new [ApiRequestHandler].
const ApiRequestHandler({
required super.config,
required this.authenticationProvider,
this.requestBodyValue,
});
/// Service responsible for authenticating this [HttpRequest].
final AuthenticationProvider authenticationProvider;
/// Throws a [BadRequestException] if any of [requiredParameters] is missing
/// from [requestData].
@protected
void checkRequiredParameters(List<String> requiredParameters) {
final Iterable<String> missingParams = requiredParameters..removeWhere(requestData!.containsKey);
if (missingParams.isNotEmpty) {
throw BadRequestException('Missing required parameter: ${missingParams.join(', ')}');
}
}
/// Gets [TokenInfo] using X-Flutter-IdToken header from an authenticated request.
@protected
Future<TokenInfo> tokenInfo(HttpRequest request) async {
return authenticationProvider.tokenInfo(request);
}
/// Throws a [BadRequestException] if any of [requiredQueryParameters] are missing from [requestData].
@protected
void checkRequiredQueryParameters(List<String> requiredQueryParameters) {
final Iterable<String> missingParams = requiredQueryParameters
..removeWhere(request!.uri.queryParameters.containsKey);
if (missingParams.isNotEmpty) {
throw BadRequestException('Missing required parameter: ${missingParams.join(', ')}');
}
}
/// The authentication context associated with the HTTP request.
///
/// This is guaranteed to be non-null. If the request was unauthenticated,
/// the request will be denied.
@protected
AuthenticatedContext? get authContext => getValue<AuthenticatedContext>(ApiKey.authContext);
/// The raw byte contents of the HTTP request body.
///
/// If the request did not specify any content in the body, this will be an
/// empty list. It will never be null.
///
/// See also:
///
/// * [requestData], which contains the JSON-decoded [Map] of the request
/// body content (if applicable).
@protected
Uint8List? get requestBody => requestBodyValue ?? getValue<Uint8List>(ApiKey.requestBody);
/// Used for injecting [requestBody] in tests.
final Uint8List? requestBodyValue;
/// The JSON data specified in the HTTP request body.
///
/// This is guaranteed to be non-null. If the request body was empty, or if
/// it contained non-JSON or binary (non-UTF-8) data, this will be an empty
/// map.
///
/// See also:
///
/// * [requestBody], which specifies the raw bytes of the HTTP request body.
@protected
Map<String, dynamic>? get requestData => getValue<Map<String, dynamic>>(ApiKey.requestData);
@override
Future<void> service(
HttpRequest request, {
Future<void> Function(HttpStatusException)? onError,
}) async {
AuthenticatedContext context;
try {
context = await authenticationProvider.authenticate(request);
} on Unauthenticated catch (error) {
final HttpResponse response = request.response;
response
..statusCode = HttpStatus.unauthorized
..write(error.message);
await response.flush();
await response.close();
return;
}
List<int> body;
try {
body = await request.expand<int>((List<int> chunk) => chunk).toList();
} catch (error) {
final HttpResponse response = request.response;
response
..statusCode = HttpStatus.internalServerError
..write('$error');
await response.flush();
await response.close();
return;
}
Map<String, dynamic>? requestData = const <String, dynamic>{};
if (body.isNotEmpty) {
try {
requestData = json.decode(utf8.decode(body)) as Map<String, dynamic>?;
} on FormatException {
// The HTTP request body is not valid UTF-8 encoded JSON. This is
// allowed; just let [requestData] be null.
} catch (error) {
final HttpResponse response = request.response;
response
..statusCode = HttpStatus.internalServerError
..write('$error');
await response.flush();
await response.close();
return;
}
}
await runZoned<Future<void>>(
() async {
await super.service(request);
},
zoneValues: <ApiKey<dynamic>, Object?>{
ApiKey.authContext: context,
ApiKey.requestBody: Uint8List.fromList(body),
ApiKey.requestData: requestData,
},
);
}
}
class ApiKey<T> extends RequestKey<T> {
const ApiKey._(super.name);
static const ApiKey<Uint8List> requestBody = ApiKey<Uint8List>._('requestBody');
static const ApiKey<AuthenticatedContext> authContext = ApiKey<AuthenticatedContext>._('authenticatedContext');
static const ApiKey<Map<String, dynamic>> requestData = ApiKey<Map<String, dynamic>>._('requestData');
}