platform/packages/http/lib/src/resource_controller.dart
2024-09-08 23:08:53 -07:00

413 lines
16 KiB
Dart
Executable file

/*
* This file is part of the Protevus Platform.
*
* (C) Protevus <developers@protevus.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
import 'dart:async';
import 'dart:ffi';
import 'dart:io';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:protevus_openapi/documentable.dart';
import 'package:protevus_auth/auth.dart';
import 'package:protevus_http/http.dart';
import 'package:protevus_openapi/v3.dart';
import 'package:protevus_runtime/runtime.dart';
import 'package:logging/logging.dart';
import 'package:meta/meta.dart';
/// Controller for operating on an HTTP Resource.
///
/// [ResourceController]s provide a means to organize the logic for all operations on an HTTP resource. They also provide conveniences for handling these operations.
///
/// This class must be subclassed. Its instance methods handle operations on an HTTP resource. For example, the following
/// are operations: 'GET /employees', 'GET /employees/:id' and 'POST /employees'. An instance method is assigned to handle one of these operations. For example:
///
/// class EmployeeController extends ResourceController {
/// @Operation.post()
/// Future<Response> createEmployee(...) async => Response.ok(null);
/// }
///
/// Instance methods must have [Operation] annotation to respond to a request (see also [Operation.get], [Operation.post], [Operation.put] and [Operation.delete]). These
/// methods are called *operation methods*. Operation methods also take a variable list of path variables. An operation method is called if the incoming request's method and
/// present path variables match the operation annotation.
///
/// For example, the route `/employees/[:id]` contains an optional route variable named `id`.
/// A subclass can implement two operation methods, one for when `id` was present and the other for when it was not:
///
/// class EmployeeController extends ResourceController {
/// // This method gets invoked when the path is '/employees'
/// @Operation.get()
/// Future<Response> getEmployees() async {
/// return Response.ok(employees);
/// }
///
/// // This method gets invoked when the path is '/employees/id'
/// @Operation.get('id')
/// Future<Response> getEmployees(@Bind.path("id") int id) async {
/// return Response.ok(employees[id]);
/// }
/// }
///
/// If there isn't an operation method for a request, an 405 Method Not Allowed error response is sent to the client and no operation methods are called.
///
/// For operation methods to correctly function, a request must have previously been handled by a [Router] to parse path variables.
///
/// Values from a request may be bound to operation method parameters. Parameters must be annotated with [Bind.path], [Bind.query], [Bind.header], or [Bind.body].
/// For example, the following binds an optional query string parameter 'name' to the 'name' argument:
///
/// class EmployeeController extends ResourceController {
/// @Operation.get()
/// Future<Response> getEmployees({@Bind.query("name") String name}) async {
/// if (name == null) {
/// return Response.ok(employees);
/// }
///
/// return Response.ok(employees.where((e) => e.name == name).toList());
/// }
/// }
///
/// Bindings will automatically parse values into other types and validate that requests have the desired values. See [Bind] for all possible bindings and https://conduit.io/docs/http/resource_controller/ for more details.
///
/// To access the request directly, use [request]. Note that the [Request.body] of [request] will be decoded prior to invoking an operation method.
abstract class ResourceController extends Controller
implements Recyclable<void> {
/// Constructor for ResourceController.
ResourceController() {
_runtime =
(RuntimeContext.current.runtimes[runtimeType] as ControllerRuntime?)
?.resourceController;
}
/// Getter for the recycled state of the controller.
@override
void get recycledState => nullptr;
/// The runtime for this ResourceController.
ResourceControllerRuntime? _runtime;
/// The request being processed by this [ResourceController].
///
/// It is this [ResourceController]'s responsibility to return a [Response] object for this request. Operation methods
/// may access this request to determine how to respond to it.
Request? request;
/// Parameters parsed from the URI of the request, if any exist.
///
/// These values are attached by a [Router] instance that precedes this [Controller]. Is null
/// if no [Router] preceded the controller and is the empty map if there are no values. The keys
/// are the case-sensitive name of the path variables as defined by [Router.route].
Map<String, String> get pathVariables => request!.path.variables;
/// Types of content this [ResourceController] will accept.
///
/// If a request is sent to this instance and has an HTTP request body and the Content-Type of the body is in this list,
/// the request will be accepted and the body will be decoded according to that Content-Type.
///
/// If the Content-Type of the request isn't within this list, the [ResourceController]
/// will automatically respond with an Unsupported Media Type response.
///
/// By default, an instance will accept HTTP request bodies with 'application/json; charset=utf-8' encoding.
List<ContentType> acceptedContentTypes = [ContentType.json];
/// The default content type of responses from this [ResourceController].
///
/// If the [Response.contentType] has not explicitly been set by a operation method in this controller, the controller will set
/// that property with this value. Defaults to "application/json".
ContentType responseContentType = ContentType.json;
/// Executed prior to handling a request, but after the [request] has been set.
///
/// This method is used to do pre-process setup and filtering. The [request] will be set, but its body will not be decoded
/// nor will the appropriate operation method be selected yet. By default, returns the request. If this method returns a [Response], this
/// controller will stop processing the request and immediately return the [Response] to the HTTP client.
///
/// May not return any other [Request] than [req].
FutureOr<RequestOrResponse> willProcessRequest(Request req) => req;
/// Callback invoked prior to decoding a request body.
///
/// This method is invoked prior to decoding the request body.
void willDecodeRequestBody(RequestBody body) {}
/// Callback to indicate when a request body has been processed.
///
/// This method is called after the body has been processed by the decoder, but prior to the request being
/// handled by the selected operation method. If there is no HTTP request body,
/// this method is not called.
void didDecodeRequestBody(RequestBody body) {}
/// Restores the state of the controller.
@override
void restore(void state) {
/* no op - fetched from static cache in Runtime */
}
/// Handles the incoming request.
@override
FutureOr<RequestOrResponse> handle(Request request) async {
this.request = request;
final preprocessedResult = await willProcessRequest(request);
if (preprocessedResult is Request) {
return _process();
} else if (preprocessedResult is Response) {
return preprocessedResult;
}
throw StateError(
"'$runtimeType' returned invalid object from 'willProcessRequest'. Must return 'Request' or 'Response'.",
);
}
/// Returns a documented list of [APIParameter] for [operation].
///
/// This method will automatically create [APIParameter]s for any bound properties and operation method arguments.
/// If an operation method requires additional parameters that cannot be bound using [Bind] annotations, override
/// this method. When overriding this method, call the superclass' implementation and add the additional parameters
/// to the returned list before returning the combined list.
@mustCallSuper
List<APIParameter>? documentOperationParameters(
APIDocumentContext context,
Operation? operation,
) {
return _runtime!.documenter
?.documentOperationParameters(this, context, operation);
}
/// Returns a documented summary for [operation].
///
/// By default, this method returns null and the summary is derived from documentation comments
/// above the operation method. You may override this method to manually add a summary to an operation.
String? documentOperationSummary(
APIDocumentContext context,
Operation? operation,
) {
return null;
}
/// Returns a documented description for [operation].
///
/// By default, this method returns null and the description is derived from documentation comments
/// above the operation method. You may override this method to manually add a description to an operation.
String? documentOperationDescription(
APIDocumentContext context,
Operation? operation,
) {
return null;
}
/// Returns a documented request body for [operation].
///
/// If an operation method binds an [Bind.body] argument or accepts form data, this method returns a [APIRequestBody]
/// that describes the bound body type. You may override this method to take an alternative approach or to augment the
/// automatically generated request body documentation.
APIRequestBody? documentOperationRequestBody(
APIDocumentContext context,
Operation? operation,
) {
return _runtime!.documenter
?.documentOperationRequestBody(this, context, operation);
}
/// Returns a map of possible responses for [operation].
///
/// To provide documentation for an operation, you must override this method and return a map of
/// possible responses. The key is a [String] representation of a status code (e.g., "200") and the value
/// is an [APIResponse] object.
Map<String, APIResponse> documentOperationResponses(
APIDocumentContext context,
Operation operation,
) {
return {"200": APIResponse("Successful response.")};
}
/// Returns a list of tags for [operation].
///
/// By default, this method will return the name of the class. This groups each operation
/// defined by this controller in the same tag. You may override this method
/// to provide additional tags. You should call the superclass' implementation to retain
/// the controller grouping tag.
List<String> documentOperationTags(
APIDocumentContext context,
Operation? operation,
) {
final tag = "$runtimeType".replaceAll("Controller", "");
return [tag];
}
/// Documents the operations for this controller.
@override
Map<String, APIOperation> documentOperations(
APIDocumentContext context,
String route,
APIPath path,
) {
return _runtime!.documenter!.documentOperations(this, context, route, path);
}
/// Documents the components for this controller.
@override
void documentComponents(APIDocumentContext context) {
_runtime!.documenter?.documentComponents(this, context);
}
/// Checks if the request content type is supported.
bool _requestContentTypeIsSupported(Request? req) {
final incomingContentType = request!.raw.headers.contentType;
return acceptedContentTypes.firstWhereOrNull((ct) {
return ct.primaryType == incomingContentType!.primaryType &&
ct.subType == incomingContentType.subType;
}) !=
null;
}
/// Returns a list of allowed HTTP methods for the given path variables.
List<String> _allowedMethodsForPathVariables(
Iterable<String?> pathVariables,
) {
return _runtime!.operations
.where((op) => op.isSuitableForRequest(null, pathVariables.toList()))
.map((op) => op.httpMethod)
.toList();
}
/// Processes the request and returns a response.
Future<Response> _process() async {
if (!request!.body.isEmpty) {
if (!_requestContentTypeIsSupported(request)) {
return Response(HttpStatus.unsupportedMediaType, null, null);
}
}
final operation = _runtime!.getOperationRuntime(
request!.raw.method,
request!.path.variables.keys.toList(),
);
if (operation == null) {
throw Response(
405,
{
"Allow": _allowedMethodsForPathVariables(request!.path.variables.keys)
.join(", ")
},
null,
);
}
if (operation.scopes != null) {
if (request!.authorization == null) {
// todo: this should be done compile-time
Logger("conduit").warning(
"'$runtimeType' must be linked to channel that contains an 'Authorizer', because "
"it uses 'Scope' annotation for one or more of its operation methods.");
throw Response.serverError();
}
if (!AuthScope.verify(operation.scopes, request!.authorization!.scopes)) {
throw Response.forbidden(
body: {
"error": "insufficient_scope",
"scope": operation.scopes!.map((s) => s.toString()).join(" ")
},
);
}
}
if (!request!.body.isEmpty) {
willDecodeRequestBody(request!.body);
await request!.body.decode();
didDecodeRequestBody(request!.body);
}
/* Begin decoding bindings */
final args = ResourceControllerOperationInvocationArgs();
final errors = <String>[];
dynamic errorCatchWrapper(ResourceControllerParameter p, f) {
try {
return f();
} on ArgumentError catch (e) {
errors.add(
"${e.message ?? 'ArgumentError'} for ${p.locationName} value '${p.name}'",
);
}
return null;
}
void checkIfMissingRequiredAndEmitErrorIfSo(
ResourceControllerParameter p,
dynamic v,
) {
if (v == null && p.isRequired) {
if (p.location == BindingType.body) {
errors.add("missing required ${p.locationName}");
} else {
errors.add("missing required ${p.locationName} '${p.name ?? ""}'");
}
return;
}
}
args.positionalArguments = operation.positionalParameters
.map((p) {
return errorCatchWrapper(p, () {
final value = p.decode(request);
checkIfMissingRequiredAndEmitErrorIfSo(p, value);
return value;
});
})
.where((p) => p != null)
.toList();
final namedEntries = operation.namedParameters
.map((p) {
return errorCatchWrapper(p, () {
final value = p.decode(request);
if (value == null) {
return null;
}
return MapEntry(p.symbolName, value);
});
})
.where((p) => p != null)
.cast<MapEntry<String, dynamic>>();
args.namedArguments = Map<String, dynamic>.fromEntries(namedEntries);
final ivarEntries = _runtime!.ivarParameters!
.map((p) {
return errorCatchWrapper(p, () {
final value = p.decode(request);
checkIfMissingRequiredAndEmitErrorIfSo(p, value);
return MapEntry(p.symbolName, value);
});
})
.where((e) => e != null)
.cast<MapEntry<String, dynamic>>();
args.instanceVariables = Map<String, dynamic>.fromEntries(ivarEntries);
/* finished decoding bindings, checking for errors */
if (errors.isNotEmpty) {
return Response.badRequest(body: {"error": errors.join(", ")});
}
/* bind and invoke */
_runtime!.applyRequestProperties(this, args);
final response = await operation.invoker(this, args);
if (!response.hasExplicitlySetContentType) {
response.contentType = responseContentType;
}
return response;
}
}