add(conduit): refactoring conduit core
This commit is contained in:
parent
fdbbe2eab5
commit
3e91c05603
24 changed files with 4519 additions and 1 deletions
21
packages/http/lib/http.dart
Normal file
21
packages/http/lib/http.dart
Normal file
|
@ -0,0 +1,21 @@
|
|||
export 'src/body_decoder.dart';
|
||||
export 'src/cache_policy.dart';
|
||||
export 'src/controller.dart';
|
||||
export 'src/cors_policy.dart';
|
||||
export 'src/file_controller.dart';
|
||||
export 'src/handler_exception.dart';
|
||||
export 'src/http_codec_repository.dart';
|
||||
export 'src/managed_object_controller.dart';
|
||||
export 'src/query_controller.dart';
|
||||
export 'src/request.dart';
|
||||
export 'src/request_body.dart';
|
||||
export 'src/request_path.dart';
|
||||
export 'src/resource_controller.dart';
|
||||
export 'src/resource_controller_bindings.dart';
|
||||
export 'src/resource_controller_interfaces.dart';
|
||||
export 'src/resource_controller_scope.dart';
|
||||
export 'src/response.dart';
|
||||
export 'src/route_node.dart';
|
||||
export 'src/route_specification.dart';
|
||||
export 'src/router.dart';
|
||||
export 'src/serializable.dart';
|
143
packages/http/lib/src/body_decoder.dart
Normal file
143
packages/http/lib/src/body_decoder.dart
Normal file
|
@ -0,0 +1,143 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Decodes [bytes] according to [contentType].
|
||||
///
|
||||
/// See [RequestBody] for a concrete implementation.
|
||||
abstract class BodyDecoder {
|
||||
BodyDecoder(Stream<List<int>> bodyByteStream)
|
||||
: _originalByteStream = bodyByteStream;
|
||||
|
||||
/// The stream of bytes to decode.
|
||||
///
|
||||
/// This stream is consumed during decoding.
|
||||
Stream<List<int>> get bytes => _originalByteStream;
|
||||
|
||||
/// Determines how [bytes] get decoded.
|
||||
///
|
||||
/// A decoder is chosen from [CodecRegistry] according to this value.
|
||||
ContentType? get contentType;
|
||||
|
||||
/// Whether or not [bytes] is empty.
|
||||
///
|
||||
/// No decoding will occur if this flag is true.
|
||||
///
|
||||
/// Concrete implementations provide an implementation for this method without inspecting
|
||||
/// [bytes].
|
||||
bool get isEmpty;
|
||||
|
||||
/// Whether or not [bytes] are available as a list after decoding has occurred.
|
||||
///
|
||||
/// By default, invoking [decode] will discard
|
||||
/// the initial bytes and only keep the decoded value. Setting this flag to true
|
||||
/// will keep a copy of the original bytes in [originalBytes].
|
||||
bool retainOriginalBytes = false;
|
||||
|
||||
/// Whether or not [bytes] have been decoded yet.
|
||||
///
|
||||
/// If [isEmpty] is true, this value is always true.
|
||||
bool get hasBeenDecoded => _decodedData != null || isEmpty;
|
||||
|
||||
/// The type of data [bytes] was decoded into.
|
||||
///
|
||||
/// Will throw an error if [bytes] have not been decoded yet.
|
||||
Type get decodedType {
|
||||
if (!hasBeenDecoded) {
|
||||
throw StateError(
|
||||
"Invalid body decoding. Must decode data prior to calling 'decodedType'.",
|
||||
);
|
||||
}
|
||||
|
||||
return _decodedData.runtimeType;
|
||||
}
|
||||
|
||||
/// The raw bytes of this request body.
|
||||
///
|
||||
/// This value is valid if [retainOriginalBytes] was set to true prior to [decode] being invoked.
|
||||
List<int>? get originalBytes {
|
||||
if (retainOriginalBytes == false) {
|
||||
throw StateError(
|
||||
"'originalBytes' were not retained. Set 'retainOriginalBytes' to true prior to decoding.",
|
||||
);
|
||||
}
|
||||
return _bytes;
|
||||
}
|
||||
|
||||
final Stream<List<int>> _originalByteStream;
|
||||
dynamic _decodedData;
|
||||
List<int>? _bytes;
|
||||
|
||||
/// Decodes this object's bytes as [T].
|
||||
///
|
||||
/// This method will select the [Codec] for [contentType] from the [CodecRegistry].
|
||||
/// The bytes of this object will be decoded according to that codec. If the codec
|
||||
/// produces a value that is not [T], a bad request error [Response] is thrown.
|
||||
///
|
||||
/// [T] must be a primitive type (String, int, double, bool, or a List or Map containing only these types).
|
||||
/// An error is not thrown if T is not one of these types, but compiled Conduit applications may fail at runtime.
|
||||
///
|
||||
/// Performance considerations:
|
||||
///
|
||||
/// The decoded value is retained, and subsequent invocations of this method return the
|
||||
/// retained value to avoid performing the decoding process again.
|
||||
Future<T> decode<T>() async {
|
||||
if (hasBeenDecoded) {
|
||||
return _cast<T>(_decodedData);
|
||||
}
|
||||
|
||||
final codec =
|
||||
CodecRegistry.defaultInstance.codecForContentType(contentType);
|
||||
final originalBytes = await _readBytes(bytes);
|
||||
|
||||
if (retainOriginalBytes) {
|
||||
_bytes = originalBytes;
|
||||
}
|
||||
|
||||
if (codec == null) {
|
||||
_decodedData = originalBytes;
|
||||
return _cast<T>(_decodedData);
|
||||
}
|
||||
|
||||
try {
|
||||
_decodedData = codec.decoder.convert(originalBytes);
|
||||
} on Response {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw Response.badRequest(
|
||||
body: {"error": "request entity could not be decoded"},
|
||||
);
|
||||
}
|
||||
|
||||
return _cast<T>(_decodedData);
|
||||
}
|
||||
|
||||
/// Returns previously decoded object as [T].
|
||||
///
|
||||
/// This method is the synchronous version of [decode]. However, [decode] must have been called
|
||||
/// prior to invoking this method or an error is thrown.
|
||||
T as<T>() {
|
||||
if (!hasBeenDecoded) {
|
||||
throw StateError("Attempted to access request body without decoding it.");
|
||||
}
|
||||
|
||||
return _cast<T>(_decodedData);
|
||||
}
|
||||
|
||||
T _cast<T>(dynamic body) {
|
||||
try {
|
||||
return RuntimeContext.current.coerce<T>(body);
|
||||
} on TypeCoercionException {
|
||||
throw Response.badRequest(
|
||||
body: {"error": "request entity was unexpected type"},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>> _readBytes(Stream<List<int>> stream) async {
|
||||
return (await stream.toList()).expand((e) => e).toList();
|
||||
}
|
||||
}
|
66
packages/http/lib/src/cache_policy.dart
Normal file
66
packages/http/lib/src/cache_policy.dart
Normal file
|
@ -0,0 +1,66 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Instances of this type provide configuration for the 'Cache-Control' header.
|
||||
///
|
||||
/// Typically used by [FileController]. See [FileController.addCachePolicy].
|
||||
class CachePolicy {
|
||||
/// Creates a new cache policy.
|
||||
///
|
||||
/// Policies applied to [Response.cachePolicy] will add the appropriate
|
||||
/// headers to that response. See properties for definitions of arguments
|
||||
/// to this constructor.
|
||||
const CachePolicy({
|
||||
this.preventIntermediateProxyCaching = false,
|
||||
this.preventCaching = false,
|
||||
this.requireConditionalRequest = false,
|
||||
this.expirationFromNow,
|
||||
});
|
||||
|
||||
/// Prevents a response from being cached by an intermediate proxy.
|
||||
///
|
||||
/// This sets 'Cache-Control: private' if true. Otherwise, 'Cache-Control: public' is used.
|
||||
final bool preventIntermediateProxyCaching;
|
||||
|
||||
/// Prevents any caching of a response by a proxy or client.
|
||||
///
|
||||
/// If true, sets 'Cache-Control: no-cache, no-store'. If this property is true,
|
||||
/// no other properties are evaluated.
|
||||
final bool preventCaching;
|
||||
|
||||
/// Requires a client to send a conditional GET to use a cached response.
|
||||
///
|
||||
/// If true, sets 'Cache-Control: no-cache'.
|
||||
final bool requireConditionalRequest;
|
||||
|
||||
/// Sets how long a resource is valid for.
|
||||
///
|
||||
/// Sets 'Cache-Control: max-age=x', where 'x' is [expirationFromNow] in seconds.
|
||||
final Duration? expirationFromNow;
|
||||
|
||||
/// Constructs a header value configured from this instance.
|
||||
///
|
||||
/// This value is used for the 'Cache-Control' header.
|
||||
String get headerValue {
|
||||
if (preventCaching) {
|
||||
return "no-cache, no-store";
|
||||
}
|
||||
|
||||
final items = [];
|
||||
|
||||
if (preventIntermediateProxyCaching) {
|
||||
items.add("private");
|
||||
} else {
|
||||
items.add("public");
|
||||
}
|
||||
|
||||
if (expirationFromNow != null) {
|
||||
items.add("max-age=${expirationFromNow!.inSeconds}");
|
||||
}
|
||||
|
||||
if (requireConditionalRequest) {
|
||||
items.add("no-cache");
|
||||
}
|
||||
|
||||
return items.join(", ");
|
||||
}
|
||||
}
|
467
packages/http/lib/src/controller.dart
Normal file
467
packages/http/lib/src/controller.dart
Normal file
|
@ -0,0 +1,467 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// The unifying protocol for [Request] and [Response] classes.
|
||||
///
|
||||
/// A [Controller] must return an instance of this type from its [Controller.handle] method.
|
||||
abstract class RequestOrResponse {}
|
||||
|
||||
/// An interface that [Controller] subclasses implement to generate a controller for each request.
|
||||
///
|
||||
/// If a [Controller] implements this interface, a [Controller] is created for each request. Controllers
|
||||
/// must implement this interface if they declare setters or non-final properties, as those properties could
|
||||
/// change during request handling.
|
||||
///
|
||||
/// A controller that implements this interface can store information that is not tied to the request
|
||||
/// to be reused across each instance of the controller type by implementing [recycledState] and [restore].
|
||||
/// Use these methods when a controller needs to construct runtime information that only needs to occur once
|
||||
/// per controller type.
|
||||
abstract class Recyclable<T> implements Controller {
|
||||
/// Returns state information that is reused across instances of this type.
|
||||
///
|
||||
/// This method is called once when this instance is first created. It is passed
|
||||
/// to each instance of this type via [restore].
|
||||
T? get recycledState;
|
||||
|
||||
/// Provides a instance of this type with the [recycledState] of this type.
|
||||
///
|
||||
/// Use this method it provide compiled runtime information to a instance.
|
||||
void restore(T? state);
|
||||
}
|
||||
|
||||
/// An interface for linking controllers.
|
||||
///
|
||||
/// All [Controller]s implement this interface.
|
||||
abstract class Linkable {
|
||||
/// See [Controller.link].
|
||||
Linkable? link(Controller Function() instantiator);
|
||||
|
||||
/// See [Controller.linkFunction].
|
||||
Linkable? linkFunction(
|
||||
FutureOr<RequestOrResponse?> Function(Request request) handle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Base class for request handling objects.
|
||||
///
|
||||
/// A controller is a discrete processing unit for requests. These units are linked
|
||||
/// together to form a series of steps that fully handle a request.
|
||||
///
|
||||
/// Subclasses must implement [handle] to respond to, modify or forward requests.
|
||||
/// This class must be subclassed. [Router] and [ResourceController] are common subclasses.
|
||||
abstract class Controller
|
||||
implements APIComponentDocumenter, APIOperationDocumenter, Linkable {
|
||||
/// Returns a stacktrace and additional details about how the request's processing in the HTTP response.
|
||||
///
|
||||
/// By default, this is false. During debugging, setting this to true can help debug Conduit applications
|
||||
/// from the HTTP client.
|
||||
static bool includeErrorDetailsInServerErrorResponses = false;
|
||||
|
||||
/// Whether or not to allow uncaught exceptions escape request controllers.
|
||||
///
|
||||
/// When this value is false - the default - all [Controller] instances handle
|
||||
/// unexpected exceptions by catching and logging them, and then returning a 500 error.
|
||||
///
|
||||
/// While running tests, it is useful to know where unexpected exceptions come from because
|
||||
/// they are an error in your code. By setting this value to true, all [Controller]s
|
||||
/// will rethrow unexpected exceptions in addition to the base behavior. This allows the stack
|
||||
/// trace of the unexpected exception to appear in test results and halt the tests with failure.
|
||||
///
|
||||
/// By default, this value is false. Do not set this value to true outside of tests.
|
||||
static bool letUncaughtExceptionsEscape = false;
|
||||
|
||||
/// Receives requests that this controller does not respond to.
|
||||
///
|
||||
/// This value is set by [link] or [linkFunction].
|
||||
Controller? get nextController => _nextController;
|
||||
|
||||
/// An instance of the 'conduit' logger.
|
||||
Logger get logger => Logger("conduit");
|
||||
|
||||
/// The CORS policy of this controller.
|
||||
CORSPolicy? policy = CORSPolicy();
|
||||
|
||||
Controller? _nextController;
|
||||
|
||||
/// Links a controller to the receiver to form a request channel.
|
||||
///
|
||||
/// Establishes a channel containing the receiver and the controller returned by [instantiator]. If
|
||||
/// the receiver does not handle a request, the controller created by [instantiator] will get an opportunity to do so.
|
||||
///
|
||||
/// [instantiator] is called immediately when invoking this function. If the returned [Controller] does not implement
|
||||
/// [Recyclable], this is the only time [instantiator] is called. The returned controller must only have properties that
|
||||
/// are marked as final.
|
||||
///
|
||||
/// If the returned controller has properties that are not marked as final, it must implement [Recyclable].
|
||||
/// When a controller implements [Recyclable], [instantiator] is called for each request that
|
||||
/// reaches this point of the channel. See [Recyclable] for more details.
|
||||
///
|
||||
/// See [linkFunction] for a variant of this method that takes a closure instead of an object.
|
||||
@override
|
||||
Linkable link(Controller Function() instantiator) {
|
||||
final instance = instantiator();
|
||||
if (instance is Recyclable) {
|
||||
_nextController = _ControllerRecycler(instantiator, instance);
|
||||
} else {
|
||||
_nextController = instantiator();
|
||||
}
|
||||
|
||||
return _nextController!;
|
||||
}
|
||||
|
||||
/// Links a function controller to the receiver to form a request channel.
|
||||
///
|
||||
/// If the receiver does not respond to a request, [handle] receives the request next.
|
||||
///
|
||||
/// See [link] for a variant of this method that takes an object instead of a closure.
|
||||
@override
|
||||
Linkable? linkFunction(
|
||||
FutureOr<RequestOrResponse?> Function(Request request) handle,
|
||||
) {
|
||||
return _nextController = _FunctionController(handle);
|
||||
}
|
||||
|
||||
/// Lifecycle callback, invoked after added to channel, but before any requests are served.
|
||||
///
|
||||
/// Subclasses override this method to provide final, one-time initialization after it has been added to a channel,
|
||||
/// but before any requests are served. This is useful for performing any caching or optimizations for this instance.
|
||||
/// For example, [Router] overrides this method to optimize its list of routes into a more efficient data structure.
|
||||
///
|
||||
/// This method is invoked immediately after [ApplicationChannel.entryPoint] completes, for each
|
||||
/// instance in the channel created by [ApplicationChannel.entryPoint]. This method will only be called once per instance.
|
||||
///
|
||||
/// Controllers added to the channel via [link] may use this method, but any values this method stores
|
||||
/// must be stored in a static structure, not the instance itself, since that instance will only be used to handle one request
|
||||
/// before it is garbage collected.
|
||||
///
|
||||
/// If you override this method you should call the superclass' implementation so that linked controllers invoke this same method.
|
||||
/// If you do not invoke the superclass' implementation, you must ensure that any linked controllers invoked this method through other means.
|
||||
void didAddToChannel() {
|
||||
_nextController?.didAddToChannel();
|
||||
}
|
||||
|
||||
/// Delivers [req] to this instance to be processed.
|
||||
///
|
||||
/// This method is the entry point of a [Request] into this [Controller].
|
||||
/// By default, it invokes this controller's [handle] method within a try-catch block
|
||||
/// that guarantees an HTTP response will be sent for [Request].
|
||||
Future? receive(Request req) async {
|
||||
if (req.isPreflightRequest) {
|
||||
return _handlePreflightRequest(req);
|
||||
}
|
||||
|
||||
Request? next;
|
||||
try {
|
||||
try {
|
||||
final result = await handle(req);
|
||||
if (result is Response) {
|
||||
await _sendResponse(req, result, includeCORSHeaders: true);
|
||||
logger.info(req.toDebugString());
|
||||
return null;
|
||||
} else if (result is Request) {
|
||||
next = result;
|
||||
}
|
||||
} on Response catch (response) {
|
||||
await _sendResponse(req, response, includeCORSHeaders: true);
|
||||
logger.info(req.toDebugString());
|
||||
return null;
|
||||
} on HandlerException catch (e) {
|
||||
await _sendResponse(req, e.response, includeCORSHeaders: true);
|
||||
logger.info(req.toDebugString());
|
||||
return null;
|
||||
}
|
||||
} catch (any, stacktrace) {
|
||||
handleError(req, any, stacktrace);
|
||||
|
||||
if (letUncaughtExceptionsEscape) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (next == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextController?.receive(next);
|
||||
}
|
||||
|
||||
/// The primary request handling method of this object.
|
||||
///
|
||||
/// Subclasses implement this method to provide their request handling logic.
|
||||
///
|
||||
/// If this method returns a [Response], it will be sent as the response for [request] linked controllers will not handle it.
|
||||
///
|
||||
/// If this method returns [request], the linked controller handles the request.
|
||||
///
|
||||
/// If this method returns null, [request] is not passed to any other controller and is not responded to. You must respond to [request]
|
||||
/// through [Request.raw].
|
||||
FutureOr<RequestOrResponse?> handle(Request request);
|
||||
|
||||
/// Executed prior to [Response] being sent.
|
||||
///
|
||||
/// This method is used to post-process [response] just before it is sent. By default, does nothing.
|
||||
/// The [response] may be altered prior to being sent. This method will be executed for all requests,
|
||||
/// including server errors.
|
||||
void willSendResponse(Response response) {}
|
||||
|
||||
/// Sends an HTTP response for a request that yields an exception or error.
|
||||
///
|
||||
/// When this controller encounters an exception or error while handling [request], this method is called to send the response.
|
||||
/// By default, it attempts to send a 500 Server Error response and logs the error and stack trace to [logger].
|
||||
///
|
||||
/// Note: If [caughtValue]'s implements [HandlerException], this method is not called.
|
||||
///
|
||||
/// If you override this method, it must not throw.
|
||||
Future handleError(
|
||||
Request request,
|
||||
dynamic caughtValue,
|
||||
StackTrace trace,
|
||||
) async {
|
||||
if (caughtValue is HTTPStreamingException) {
|
||||
logger.severe(
|
||||
request.toDebugString(includeHeaders: true),
|
||||
caughtValue.underlyingException,
|
||||
caughtValue.trace,
|
||||
);
|
||||
|
||||
request.response.close().catchError((_) => null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final body = includeErrorDetailsInServerErrorResponses
|
||||
? {
|
||||
"controller": "$runtimeType",
|
||||
"error": "$caughtValue.",
|
||||
"stacktrace": trace.toString()
|
||||
}
|
||||
: null;
|
||||
|
||||
final response = Response.serverError(body: body)
|
||||
..contentType = ContentType.json;
|
||||
|
||||
await _sendResponse(request, response, includeCORSHeaders: true);
|
||||
|
||||
logger.severe(
|
||||
request.toDebugString(includeHeaders: true),
|
||||
caughtValue,
|
||||
trace,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.severe("Failed to send response, draining request. Reason: $e");
|
||||
|
||||
request.raw.drain().catchError((_) => null);
|
||||
}
|
||||
}
|
||||
|
||||
void applyCORSHeadersIfNecessary(Request req, Response resp) {
|
||||
if (req.isCORSRequest && !req.isPreflightRequest) {
|
||||
final lastPolicyController = _lastController;
|
||||
final p = lastPolicyController.policy;
|
||||
if (p != null) {
|
||||
if (p.isRequestOriginAllowed(req.raw)) {
|
||||
resp.headers.addAll(p.headersForRequest(req));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIPath> documentPaths(APIDocumentContext context) =>
|
||||
nextController?.documentPaths(context) ?? {};
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
if (nextController == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return nextController!.documentOperations(context, route, path);
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) =>
|
||||
nextController?.documentComponents(context);
|
||||
|
||||
Future? _handlePreflightRequest(Request req) async {
|
||||
Controller controllerToDictatePolicy;
|
||||
try {
|
||||
final lastControllerInChain = _lastController;
|
||||
if (lastControllerInChain != this) {
|
||||
controllerToDictatePolicy = lastControllerInChain;
|
||||
} else {
|
||||
if (policy != null) {
|
||||
if (!policy!.validatePreflightRequest(req.raw)) {
|
||||
await _sendResponse(req, Response.forbidden());
|
||||
logger.info(req.toDebugString(includeHeaders: true));
|
||||
} else {
|
||||
await _sendResponse(req, policy!.preflightResponse(req));
|
||||
logger.info(req.toDebugString());
|
||||
}
|
||||
|
||||
return null;
|
||||
} else {
|
||||
// If we don't have a policy, then a preflight request makes no sense.
|
||||
await _sendResponse(req, Response.forbidden());
|
||||
logger.info(req.toDebugString(includeHeaders: true));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (any, stacktrace) {
|
||||
return handleError(req, any, stacktrace);
|
||||
}
|
||||
|
||||
return controllerToDictatePolicy.receive(req);
|
||||
}
|
||||
|
||||
Future _sendResponse(
|
||||
Request request,
|
||||
Response response, {
|
||||
bool includeCORSHeaders = false,
|
||||
}) {
|
||||
if (includeCORSHeaders) {
|
||||
applyCORSHeadersIfNecessary(request, response);
|
||||
}
|
||||
willSendResponse(response);
|
||||
|
||||
return request.respond(response);
|
||||
}
|
||||
|
||||
Controller get _lastController {
|
||||
Controller controller = this;
|
||||
while (controller.nextController != null) {
|
||||
controller = controller.nextController!;
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
|
||||
@PreventCompilation()
|
||||
class _ControllerRecycler<T> extends Controller {
|
||||
_ControllerRecycler(this.generator, Recyclable<T> instance) {
|
||||
recycleState = instance.recycledState;
|
||||
nextInstanceToReceive = instance;
|
||||
}
|
||||
|
||||
Controller Function() generator;
|
||||
CORSPolicy? policyOverride;
|
||||
T? recycleState;
|
||||
|
||||
Recyclable<T>? _nextInstanceToReceive;
|
||||
|
||||
Recyclable<T>? get nextInstanceToReceive => _nextInstanceToReceive;
|
||||
|
||||
set nextInstanceToReceive(Recyclable<T>? instance) {
|
||||
_nextInstanceToReceive = instance;
|
||||
instance?.restore(recycleState);
|
||||
instance?._nextController = nextController;
|
||||
if (policyOverride != null) {
|
||||
instance?.policy = policyOverride;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
CORSPolicy? get policy {
|
||||
return nextInstanceToReceive?.policy;
|
||||
}
|
||||
|
||||
@override
|
||||
set policy(CORSPolicy? p) {
|
||||
policyOverride = p;
|
||||
}
|
||||
|
||||
@override
|
||||
Linkable link(Controller Function() instantiator) {
|
||||
final c = super.link(instantiator);
|
||||
nextInstanceToReceive?._nextController = c as Controller;
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
Linkable? linkFunction(
|
||||
FutureOr<RequestOrResponse?> Function(Request request) handle,
|
||||
) {
|
||||
final c = super.linkFunction(handle);
|
||||
nextInstanceToReceive?._nextController = c as Controller?;
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
Future? receive(Request req) {
|
||||
final next = nextInstanceToReceive;
|
||||
nextInstanceToReceive = generator() as Recyclable<T>;
|
||||
return next!.receive(req);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> handle(Request request) {
|
||||
throw StateError("_ControllerRecycler invoked handle. This is a bug.");
|
||||
}
|
||||
|
||||
@override
|
||||
void didAddToChannel() {
|
||||
// don't call super, since nextInstanceToReceive's nextController is set to the same instance,
|
||||
// and it must call nextController.prepare
|
||||
nextInstanceToReceive?.didAddToChannel();
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext components) =>
|
||||
nextInstanceToReceive?.documentComponents(components);
|
||||
|
||||
@override
|
||||
Map<String, APIPath> documentPaths(APIDocumentContext components) =>
|
||||
nextInstanceToReceive?.documentPaths(components) ?? {};
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext components,
|
||||
String route,
|
||||
APIPath path,
|
||||
) =>
|
||||
nextInstanceToReceive?.documentOperations(components, route, path) ?? {};
|
||||
}
|
||||
|
||||
@PreventCompilation()
|
||||
class _FunctionController extends Controller {
|
||||
_FunctionController(this._handler);
|
||||
|
||||
final FutureOr<RequestOrResponse?> Function(Request) _handler;
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse?> handle(Request request) {
|
||||
return _handler(request);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
if (nextController == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return nextController!.documentOperations(context, route, path);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ControllerRuntime {
|
||||
bool get isMutable;
|
||||
|
||||
ResourceControllerRuntime? get resourceController;
|
||||
}
|
201
packages/http/lib/src/cors_policy.dart
Normal file
201
packages/http/lib/src/cors_policy.dart
Normal file
|
@ -0,0 +1,201 @@
|
|||
import 'dart:io';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Describes a CORS policy for a [Controller].
|
||||
///
|
||||
/// A CORS policy describes allowed origins, accepted HTTP methods and headers, exposed response headers
|
||||
/// and other values used by browsers to manage XHR requests to a Conduit application.
|
||||
///
|
||||
/// Every [Controller] has a [Controller.policy]. By default, this value is [defaultPolicy], which is quite permissive.
|
||||
///
|
||||
/// Modifications to policy for a specific [Controller] can be accomplished in the initializer of the controller.
|
||||
///
|
||||
/// Application-wide defaults can be managed by modifying [defaultPolicy] in a [ApplicationChannel]'s constructor.
|
||||
///
|
||||
class CORSPolicy {
|
||||
/// Create a new instance of [CORSPolicy].
|
||||
///
|
||||
/// Values are set to match [defaultPolicy].
|
||||
CORSPolicy() {
|
||||
final def = defaultPolicy;
|
||||
allowedOrigins = List.from(def.allowedOrigins);
|
||||
allowCredentials = def.allowCredentials;
|
||||
exposedResponseHeaders = List.from(def.exposedResponseHeaders);
|
||||
allowedMethods = List.from(def.allowedMethods);
|
||||
allowedRequestHeaders = List.from(def.allowedRequestHeaders);
|
||||
cacheInSeconds = def.cacheInSeconds;
|
||||
}
|
||||
|
||||
CORSPolicy._defaults() {
|
||||
allowedOrigins = ["*"];
|
||||
allowCredentials = true;
|
||||
exposedResponseHeaders = [];
|
||||
allowedMethods = ["POST", "PUT", "DELETE", "GET"];
|
||||
allowedRequestHeaders = [
|
||||
"origin",
|
||||
"authorization",
|
||||
"x-requested-with",
|
||||
"x-forwarded-for",
|
||||
"content-type"
|
||||
];
|
||||
cacheInSeconds = 86400;
|
||||
}
|
||||
|
||||
/// The default CORS policy.
|
||||
///
|
||||
/// You may modify this default policy. All instances of [CORSPolicy] are instantiated
|
||||
/// using the values of this default policy. Do not modify this property
|
||||
/// unless you want the defaults to change application-wide.
|
||||
static CORSPolicy get defaultPolicy {
|
||||
return _defaultPolicy ??= CORSPolicy._defaults();
|
||||
}
|
||||
|
||||
static CORSPolicy? _defaultPolicy;
|
||||
|
||||
/// List of 'Simple' CORS headers.
|
||||
///
|
||||
/// These are headers that are considered acceptable as part of any CORS request and cannot be changed.
|
||||
static const List<String> simpleRequestHeaders = [
|
||||
"accept",
|
||||
"accept-language",
|
||||
"content-language",
|
||||
"content-type"
|
||||
];
|
||||
|
||||
/// List of 'Simple' CORS Response headers.
|
||||
///
|
||||
/// These headers can be returned in a response without explicitly exposing them and cannot be changed.
|
||||
static const List<String> simpleResponseHeaders = [
|
||||
"cache-control",
|
||||
"content-language",
|
||||
"content-type",
|
||||
"content-type",
|
||||
"expires",
|
||||
"last-modified",
|
||||
"pragma"
|
||||
];
|
||||
|
||||
/// The list of case-sensitive allowed origins.
|
||||
///
|
||||
/// Defaults to '*'. Case-sensitive. In the specification (http://www.w3.org/TR/cors/), this is 'list of origins'.
|
||||
late List<String> allowedOrigins;
|
||||
|
||||
/// Whether or not to allow use of credentials, including Authorization and cookies.
|
||||
///
|
||||
/// Defaults to true. In the specification (http://www.w3.org/TR/cors/), this is 'supports credentials'.
|
||||
late bool allowCredentials;
|
||||
|
||||
/// Which response headers to expose to the client.
|
||||
///
|
||||
/// Defaults to empty. In the specification (http://www.w3.org/TR/cors/), this is 'list of exposed headers'.
|
||||
///
|
||||
///
|
||||
late List<String> exposedResponseHeaders;
|
||||
|
||||
/// Which HTTP methods are allowed.
|
||||
///
|
||||
/// Defaults to POST, PUT, DELETE, and GET. Case-sensitive. In the specification (http://www.w3.org/TR/cors/), this is 'list of methods'.
|
||||
late List<String> allowedMethods;
|
||||
|
||||
/// The allowed request headers.
|
||||
///
|
||||
/// Defaults to authorization, x-requested-with, x-forwarded-for. Must be lowercase.
|
||||
/// Use in conjunction with [simpleRequestHeaders]. In the specification (http://www.w3.org/TR/cors/), this is 'list of headers'.
|
||||
late List<String> allowedRequestHeaders;
|
||||
|
||||
/// The number of seconds to cache a pre-flight request for a requesting client.
|
||||
int? cacheInSeconds;
|
||||
|
||||
/// Returns a map of HTTP headers for a request based on this policy.
|
||||
///
|
||||
/// This will add Access-Control-Allow-Origin, Access-Control-Expose-Headers and Access-Control-Allow-Credentials
|
||||
/// depending on the this policy.
|
||||
Map<String, dynamic> headersForRequest(Request request) {
|
||||
final origin = request.raw.headers.value("origin");
|
||||
|
||||
final headers = <String, dynamic>{};
|
||||
headers["Access-Control-Allow-Origin"] = origin;
|
||||
|
||||
if (exposedResponseHeaders.isNotEmpty) {
|
||||
headers["Access-Control-Expose-Headers"] =
|
||||
exposedResponseHeaders.join(", ");
|
||||
}
|
||||
|
||||
if (allowCredentials) {
|
||||
headers["Access-Control-Allow-Credentials"] = "true";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/// Whether or not this policy allows the Origin of the [request].
|
||||
///
|
||||
/// Will return true if [allowedOrigins] contains the case-sensitive Origin of the [request],
|
||||
/// or that [allowedOrigins] contains *.
|
||||
/// This method is invoked internally by [Controller]s that have a [Controller.policy].
|
||||
bool isRequestOriginAllowed(HttpRequest request) {
|
||||
if (allowedOrigins.contains("*")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final origin = request.headers.value("origin");
|
||||
if (allowedOrigins.contains(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Validates whether or not a preflight request matches this policy.
|
||||
///
|
||||
/// Will return true if the policy agrees with the Access-Control-Request-* headers of the request, otherwise, false.
|
||||
/// This method is invoked internally by [Controller]s that have a [Controller.policy].
|
||||
bool validatePreflightRequest(HttpRequest request) {
|
||||
if (!isRequestOriginAllowed(request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final method = request.headers.value("access-control-request-method");
|
||||
if (!allowedMethods.contains(method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final requestedHeaders = request.headers
|
||||
.value("access-control-request-headers")
|
||||
?.split(",")
|
||||
.map((str) => str.trim().toLowerCase())
|
||||
.toList();
|
||||
if (requestedHeaders?.isNotEmpty ?? false) {
|
||||
final nonSimpleHeaders =
|
||||
requestedHeaders!.where((str) => !simpleRequestHeaders.contains(str));
|
||||
if (nonSimpleHeaders.any((h) => !allowedRequestHeaders.contains(h))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Returns a preflight response for a given [Request].
|
||||
///
|
||||
/// Contains the Access-Control-Allow-* headers for a CORS preflight request according
|
||||
/// to this policy.
|
||||
/// This method is invoked internally by [Controller]s that have a [Controller.policy].
|
||||
Response preflightResponse(Request req) {
|
||||
final headers = {
|
||||
"Access-Control-Allow-Origin": req.raw.headers.value("origin"),
|
||||
"Access-Control-Allow-Methods": allowedMethods.join(", "),
|
||||
"Access-Control-Allow-Headers": allowedRequestHeaders.join(", ")
|
||||
};
|
||||
|
||||
if (allowCredentials) {
|
||||
headers["Access-Control-Allow-Credentials"] = "true";
|
||||
}
|
||||
|
||||
if (cacheInSeconds != null) {
|
||||
headers["Access-Control-Max-Age"] = "$cacheInSeconds";
|
||||
}
|
||||
|
||||
return Response.ok(null, headers: headers);
|
||||
}
|
||||
}
|
241
packages/http/lib/src/file_controller.dart
Normal file
241
packages/http/lib/src/file_controller.dart
Normal file
|
@ -0,0 +1,241 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
typedef FileControllerClosure = FutureOr<Response> Function(
|
||||
FileController controller,
|
||||
Request req,
|
||||
);
|
||||
|
||||
/// Serves files from a directory on the filesystem.
|
||||
///
|
||||
/// See the constructor for usage.
|
||||
class FileController extends Controller {
|
||||
/// Creates a controller that serves files from [pathOfDirectoryToServe].
|
||||
///
|
||||
/// File controllers append the path of an HTTP request to [pathOfDirectoryToServe] and attempt to read the file at that location.
|
||||
///
|
||||
/// If the file exists, its contents are sent in the HTTP Response body. If the file does not exist, a 404 Not Found error is returned by default.
|
||||
///
|
||||
/// A route to this controller must contain the match-all segment (`*`). For example:
|
||||
///
|
||||
/// router
|
||||
/// .route("/site/*")
|
||||
/// .link(() => FileController("build/web"));
|
||||
///
|
||||
/// In the above, `GET /site/index.html` would return the file `build/web/index.html`.
|
||||
///
|
||||
/// If [pathOfDirectoryToServe] contains a leading slash, it is an absolute path. Otherwise, it is relative to the current working directory
|
||||
/// of the running application.
|
||||
///
|
||||
/// If no file is found, the default behavior is to return a 404 Not Found. (If the [Request] accepts 'text/html', a simple 404 page is returned.) You may
|
||||
/// override this behavior by providing [onFileNotFound].
|
||||
///
|
||||
/// The content type of the response is determined by the file extension of the served file. There are many built-in extension-to-content-type mappings and you may
|
||||
/// add more with [setContentTypeForExtension]. Unknown file extension will result in `application/octet-stream` content-type responses.
|
||||
///
|
||||
/// The contents of a file will be compressed with 'gzip' if the request allows for it and the content-type of the file can be compressed
|
||||
/// according to [CodecRegistry].
|
||||
///
|
||||
/// Note that the 'Last-Modified' header is always applied to a response served from this instance.
|
||||
FileController(
|
||||
String pathOfDirectoryToServe, {
|
||||
FileControllerClosure? onFileNotFound,
|
||||
}) : _servingDirectory = Uri.directory(pathOfDirectoryToServe),
|
||||
_onFileNotFound = onFileNotFound;
|
||||
|
||||
static final Map<String, ContentType> _defaultExtensionMap = {
|
||||
/* Web content */
|
||||
"html": ContentType("text", "html", charset: "utf-8"),
|
||||
"css": ContentType("text", "css", charset: "utf-8"),
|
||||
"js": ContentType("application", "javascript", charset: "utf-8"),
|
||||
"json": ContentType("application", "json", charset: "utf-8"),
|
||||
|
||||
/* Images */
|
||||
"jpg": ContentType("image", "jpeg"),
|
||||
"jpeg": ContentType("image", "jpeg"),
|
||||
"eps": ContentType("application", "postscript"),
|
||||
"png": ContentType("image", "png"),
|
||||
"gif": ContentType("image", "gif"),
|
||||
"bmp": ContentType("image", "bmp"),
|
||||
"tiff": ContentType("image", "tiff"),
|
||||
"tif": ContentType("image", "tiff"),
|
||||
"ico": ContentType("image", "x-icon"),
|
||||
"svg": ContentType("image", "svg+xml"),
|
||||
|
||||
/* Documents */
|
||||
"rtf": ContentType("application", "rtf"),
|
||||
"pdf": ContentType("application", "pdf"),
|
||||
"csv": ContentType("text", "plain", charset: "utf-8"),
|
||||
"md": ContentType("text", "plain", charset: "utf-8"),
|
||||
|
||||
/* Fonts */
|
||||
"ttf": ContentType("font", "ttf"),
|
||||
"eot": ContentType("application", "vnd.ms-fontobject"),
|
||||
"woff": ContentType("font", "woff"),
|
||||
"otf": ContentType("font", "otf"),
|
||||
};
|
||||
|
||||
final Map<String, ContentType> _extensionMap = Map.from(_defaultExtensionMap);
|
||||
final List<_PolicyPair?> _policyPairs = [];
|
||||
final Uri _servingDirectory;
|
||||
final FutureOr<Response> Function(
|
||||
FileController,
|
||||
Request,
|
||||
)? _onFileNotFound;
|
||||
|
||||
/// Returns a [ContentType] for a file extension.
|
||||
///
|
||||
/// Returns the associated content type for [extension], if one exists. Extension may have leading '.',
|
||||
/// e.g. both '.jpg' and 'jpg' are valid inputs to this method.
|
||||
///
|
||||
/// Returns null if there is no entry for [extension]. Entries can be added with [setContentTypeForExtension].
|
||||
ContentType? contentTypeForExtension(String extension) {
|
||||
if (extension.startsWith(".")) {
|
||||
return _extensionMap[extension.substring(1)];
|
||||
}
|
||||
return _extensionMap[extension];
|
||||
}
|
||||
|
||||
/// Sets the associated content type for a file extension.
|
||||
///
|
||||
/// When a file with [extension] file extension is served by any instance of this type,
|
||||
/// the [contentType] will be sent as the response's Content-Type header.
|
||||
void setContentTypeForExtension(String extension, ContentType contentType) {
|
||||
_extensionMap[extension] = contentType;
|
||||
}
|
||||
|
||||
/// Add a cache policy for file paths that return true for [shouldApplyToPath].
|
||||
///
|
||||
/// When this instance serves a file, the headers determined by [policy]
|
||||
/// will be applied to files whose path returns true for [shouldApplyToPath].
|
||||
///
|
||||
/// If a path would meet the criteria for multiple [shouldApplyToPath] functions added to this instance,
|
||||
/// the policy added earliest to this instance will be applied.
|
||||
///
|
||||
/// For example, the following adds a set of cache policies that will apply 'Cache-Control: no-cache, no-store' to '.widget' files,
|
||||
/// and 'Cache-Control: public' for any other files:
|
||||
///
|
||||
/// fileController.addCachePolicy(const CachePolicy(preventCaching: true),
|
||||
/// (p) => p.endsWith(".widget"));
|
||||
/// fileController.addCachePolicy(const CachePolicy(),
|
||||
/// (p) => true);
|
||||
///
|
||||
/// Whereas the following incorrect example would apply 'Cache-Control: public' to '.widget' files because the first policy
|
||||
/// would always apply to it and the second policy would be ignored:
|
||||
///
|
||||
/// fileController.addCachePolicy(const CachePolicy(),
|
||||
/// (p) => true);
|
||||
/// fileController.addCachePolicy(const CachePolicy(preventCaching: true),
|
||||
/// (p) => p.endsWith(".widget"));
|
||||
///
|
||||
/// Note that the 'Last-Modified' header is always applied to a response served from this instance.
|
||||
///
|
||||
void addCachePolicy(
|
||||
CachePolicy policy,
|
||||
bool Function(String path) shouldApplyToPath,
|
||||
) {
|
||||
_policyPairs.add(_PolicyPair(policy, shouldApplyToPath));
|
||||
}
|
||||
|
||||
/// Returns the [CachePolicy] for [path].
|
||||
///
|
||||
/// Evaluates each policy added by [addCachePolicy] against the [path] and
|
||||
/// returns it if exists.
|
||||
CachePolicy? cachePolicyForPath(String path) {
|
||||
return _policyPairs
|
||||
.firstWhere(
|
||||
(pair) => pair?.shouldApplyToPath(path) ?? false,
|
||||
orElse: () => null,
|
||||
)
|
||||
?.policy;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RequestOrResponse> handle(Request request) async {
|
||||
if (request.method != "GET") {
|
||||
return Response(HttpStatus.methodNotAllowed, null, null);
|
||||
}
|
||||
|
||||
final relativePath = request.path.remainingPath;
|
||||
final fileUri = _servingDirectory.resolve(relativePath ?? "");
|
||||
File file;
|
||||
if (FileSystemEntity.isDirectorySync(fileUri.toFilePath())) {
|
||||
file = File.fromUri(fileUri.resolve("index.html"));
|
||||
} else {
|
||||
file = File.fromUri(fileUri);
|
||||
}
|
||||
|
||||
if (!file.existsSync()) {
|
||||
if (_onFileNotFound != null) {
|
||||
return _onFileNotFound(this, request);
|
||||
}
|
||||
|
||||
final response = Response.notFound();
|
||||
if (request.acceptsContentType(ContentType.html)) {
|
||||
response
|
||||
..body = "<html><h3>404 Not Found</h3></html>"
|
||||
..contentType = ContentType.html;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
final lastModifiedDate = file.lastModifiedSync();
|
||||
final ifModifiedSince =
|
||||
request.raw.headers.value(HttpHeaders.ifModifiedSinceHeader);
|
||||
if (ifModifiedSince != null) {
|
||||
final date = HttpDate.parse(ifModifiedSince);
|
||||
if (!lastModifiedDate.isAfter(date)) {
|
||||
return Response.notModified(lastModifiedDate, _policyForFile(file));
|
||||
}
|
||||
}
|
||||
|
||||
final lastModifiedDateStringValue = HttpDate.format(lastModifiedDate);
|
||||
final contentType = contentTypeForExtension(path.extension(file.path)) ??
|
||||
ContentType("application", "octet-stream");
|
||||
final byteStream = file.openRead();
|
||||
|
||||
return Response.ok(
|
||||
byteStream,
|
||||
headers: {HttpHeaders.lastModifiedHeader: lastModifiedDateStringValue},
|
||||
)
|
||||
..cachePolicy = _policyForFile(file)
|
||||
..encodeBody = false
|
||||
..contentType = contentType;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
return {
|
||||
"get": APIOperation(
|
||||
"getFile",
|
||||
{
|
||||
"200": APIResponse(
|
||||
"Successful file fetch.",
|
||||
content: {"*/*": APIMediaType(schema: APISchemaObject.file())},
|
||||
),
|
||||
"404": APIResponse("No file exists at path.")
|
||||
},
|
||||
description: "Content-Type is determined by the suffix of the file.",
|
||||
summary: "Returns the contents of a file on the server's filesystem.",
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
CachePolicy? _policyForFile(File file) => cachePolicyForPath(file.path);
|
||||
}
|
||||
|
||||
class _PolicyPair {
|
||||
_PolicyPair(this.policy, this.shouldApplyToPath);
|
||||
|
||||
final bool Function(String) shouldApplyToPath;
|
||||
final CachePolicy policy;
|
||||
}
|
9
packages/http/lib/src/handler_exception.dart
Normal file
9
packages/http/lib/src/handler_exception.dart
Normal file
|
@ -0,0 +1,9 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
class HandlerException implements Exception {
|
||||
HandlerException(this._response);
|
||||
|
||||
Response get response => _response;
|
||||
|
||||
final Response _response;
|
||||
}
|
261
packages/http/lib/src/http_codec_repository.dart
Normal file
261
packages/http/lib/src/http_codec_repository.dart
Normal file
|
@ -0,0 +1,261 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Provides encoding and decoding services based on the [ContentType] of a [Request] or [Response].
|
||||
///
|
||||
/// The [defaultInstance] provides a lookup table of [ContentType] to [Codec]. By default,
|
||||
/// 'application/json', 'application/x-www-form-urlencoded' and 'text/*' content types have codecs and can
|
||||
/// transform a [Response.body] into a list of bytes that can be transferred as an HTTP response body.
|
||||
///
|
||||
/// Additional mappings are added via [add]. This method must be called per-isolate and it is recommended
|
||||
/// to add mappings in an application's [ApplicationChannel] subclass constructor.
|
||||
class CodecRegistry {
|
||||
CodecRegistry._() {
|
||||
add(
|
||||
ContentType("application", "json", charset: "utf-8"),
|
||||
const JsonCodec(),
|
||||
);
|
||||
add(
|
||||
ContentType("application", "x-www-form-urlencoded", charset: "utf-8"),
|
||||
const _FormCodec(),
|
||||
);
|
||||
setAllowsCompression(ContentType("text", "*"), true);
|
||||
setAllowsCompression(ContentType("application", "javascript"), true);
|
||||
setAllowsCompression(ContentType("text", "event-stream"), false);
|
||||
}
|
||||
|
||||
/// The instance used by Conduit to encode and decode HTTP bodies.
|
||||
///
|
||||
/// Custom codecs must be added to this instance. This value is guaranteed to be non-null.
|
||||
static CodecRegistry get defaultInstance => _defaultInstance;
|
||||
static final CodecRegistry _defaultInstance = CodecRegistry._();
|
||||
|
||||
final Map<String, Codec> _primaryTypeCodecs = {};
|
||||
final Map<String, Map<String, Codec>> _fullySpecificedCodecs = {};
|
||||
final Map<String, bool> _primaryTypeCompressionMap = {};
|
||||
final Map<String, Map<String, bool>> _fullySpecifiedCompressionMap = {};
|
||||
final Map<String, Map<String, String?>> _defaultCharsetMap = {};
|
||||
|
||||
/// Adds a custom [codec] for [contentType].
|
||||
///
|
||||
/// The body of a [Response] sent with [contentType] will be transformed by [codec]. A [Request] with [contentType] Content-Type
|
||||
/// will be decode its [Request.body] with [codec].
|
||||
///
|
||||
/// [codec] must produce a [List<int>] (or used chunked conversion to create a `Stream<List<int>>`).
|
||||
///
|
||||
/// [contentType]'s subtype may be `*`; all Content-Type's with a matching [ContentType.primaryType] will be
|
||||
/// encoded or decoded by [codec], regardless of [ContentType.subType]. For example, if [contentType] is `text/*`, then all
|
||||
/// `text/` (`text/html`, `text/plain`, etc.) content types are converted by [codec].
|
||||
///
|
||||
/// The most specific codec for a content type is chosen when converting an HTTP body. For example, if both `text/*`
|
||||
/// and `text/html` have been added through this method, a [Response] with content type `text/html` will select the codec
|
||||
/// associated with `text/html` and not `text/*`.
|
||||
///
|
||||
/// [allowCompression] chooses whether or not response bodies are compressed with [gzip] when using [contentType].
|
||||
/// Media types like images and audio files should avoid setting [allowCompression] because they are already compressed.
|
||||
///
|
||||
/// A response with a content type not in this instance will be sent unchanged to the HTTP client (and therefore must be [List<int>]
|
||||
///
|
||||
/// The [ContentType.charset] is not evaluated when selecting the codec for a content type. However, a charset indicates the default
|
||||
/// used when a request's Content-Type header omits a charset. For example, in order to decode JSON data, the request body must first be decoded
|
||||
/// from a list of bytes into a [String]. If a request omits the charset, this first step is would not be applied and the JSON codec would attempt
|
||||
/// to decode a list of bytes instead of a [String] and would fail. Thus, `application/json` is added through the following:
|
||||
///
|
||||
/// CodecRegistry.defaultInstance.add(
|
||||
/// ContentType("application", "json", charset: "utf-8"), const JsonCodec(), allowsCompression: true);
|
||||
///
|
||||
/// In the event that a request is sent without a charset, the codec will automatically apply a UTF8 decode step because of this default.
|
||||
///
|
||||
/// Only use default charsets when the codec must first be decoded into a [String].
|
||||
void add(
|
||||
ContentType contentType,
|
||||
Codec codec, {
|
||||
bool allowCompression = true,
|
||||
}) {
|
||||
if (contentType.subType == "*") {
|
||||
_primaryTypeCodecs[contentType.primaryType] = codec;
|
||||
_primaryTypeCompressionMap[contentType.primaryType] = allowCompression;
|
||||
} else {
|
||||
final innerCodecs = _fullySpecificedCodecs[contentType.primaryType] ?? {};
|
||||
innerCodecs[contentType.subType] = codec;
|
||||
_fullySpecificedCodecs[contentType.primaryType] = innerCodecs;
|
||||
|
||||
final innerCompress =
|
||||
_fullySpecifiedCompressionMap[contentType.primaryType] ?? {};
|
||||
innerCompress[contentType.subType] = allowCompression;
|
||||
_fullySpecifiedCompressionMap[contentType.primaryType] = innerCompress;
|
||||
}
|
||||
|
||||
if (contentType.charset != null) {
|
||||
final innerCodecs = _defaultCharsetMap[contentType.primaryType] ?? {};
|
||||
innerCodecs[contentType.subType] = contentType.charset;
|
||||
_defaultCharsetMap[contentType.primaryType] = innerCodecs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles whether HTTP bodies of [contentType] are compressed with GZIP.
|
||||
///
|
||||
/// Use this method when wanting to compress a [Response.body], but there is no need for a [Codec] to transform
|
||||
/// the body object.
|
||||
void setAllowsCompression(ContentType contentType, bool allowed) {
|
||||
if (contentType.subType == "*") {
|
||||
_primaryTypeCompressionMap[contentType.primaryType] = allowed;
|
||||
} else {
|
||||
final innerCompress =
|
||||
_fullySpecifiedCompressionMap[contentType.primaryType] ?? {};
|
||||
innerCompress[contentType.subType] = allowed;
|
||||
_fullySpecifiedCompressionMap[contentType.primaryType] = innerCompress;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not [contentType] has been configured to be compressed.
|
||||
///
|
||||
/// See also [setAllowsCompression].
|
||||
bool isContentTypeCompressable(ContentType? contentType) {
|
||||
final subtypeCompress =
|
||||
_fullySpecifiedCompressionMap[contentType?.primaryType];
|
||||
if (subtypeCompress != null) {
|
||||
if (subtypeCompress.containsKey(contentType?.subType)) {
|
||||
return subtypeCompress[contentType?.subType] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
return _primaryTypeCompressionMap[contentType?.primaryType] ?? false;
|
||||
}
|
||||
|
||||
/// Returns a [Codec] for [contentType].
|
||||
///
|
||||
/// See [add].
|
||||
Codec<dynamic, List<int>>? codecForContentType(ContentType? contentType) {
|
||||
if (contentType == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Codec? contentCodec;
|
||||
Codec<String, List<int>>? charsetCodec;
|
||||
|
||||
final subtypes = _fullySpecificedCodecs[contentType.primaryType];
|
||||
if (subtypes != null) {
|
||||
contentCodec = subtypes[contentType.subType];
|
||||
}
|
||||
|
||||
contentCodec ??= _primaryTypeCodecs[contentType.primaryType];
|
||||
|
||||
if ((contentType.charset?.length ?? 0) > 0) {
|
||||
charsetCodec = _codecForCharset(contentType.charset);
|
||||
} else if (contentType.primaryType == "text" && contentCodec == null) {
|
||||
charsetCodec = latin1;
|
||||
} else {
|
||||
charsetCodec = _defaultCharsetCodecForType(contentType);
|
||||
}
|
||||
|
||||
if (contentCodec != null) {
|
||||
if (charsetCodec != null) {
|
||||
return contentCodec.fuse(charsetCodec);
|
||||
}
|
||||
if (contentCodec is! Codec<dynamic, List<int>>) {
|
||||
throw StateError("Invalid codec selected. Does not emit 'List<int>'.");
|
||||
}
|
||||
return contentCodec;
|
||||
}
|
||||
|
||||
if (charsetCodec != null) {
|
||||
return charsetCodec;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Codec<String, List<int>> _codecForCharset(String? charset) {
|
||||
final encoding = Encoding.getByName(charset);
|
||||
if (encoding == null) {
|
||||
throw Response(415, null, {"error": "invalid charset '$charset'"});
|
||||
}
|
||||
|
||||
return encoding;
|
||||
}
|
||||
|
||||
Codec<String, List<int>>? _defaultCharsetCodecForType(ContentType type) {
|
||||
final inner = _defaultCharsetMap[type.primaryType];
|
||||
if (inner == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final encodingName = inner[type.subType] ?? inner["*"];
|
||||
if (encodingName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.getByName(encodingName);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormCodec extends Codec<Map<String, dynamic>?, dynamic> {
|
||||
const _FormCodec();
|
||||
|
||||
@override
|
||||
Converter<Map<String, dynamic>, String> get encoder => const _FormEncoder();
|
||||
|
||||
@override
|
||||
Converter<String, Map<String, dynamic>> get decoder => const _FormDecoder();
|
||||
}
|
||||
|
||||
class _FormEncoder extends Converter<Map<String, dynamic>, String> {
|
||||
const _FormEncoder();
|
||||
|
||||
@override
|
||||
String convert(Map<String, dynamic> data) {
|
||||
return data.keys.map((k) => _encodePair(k, data[k])).join("&");
|
||||
}
|
||||
|
||||
String _encodePair(String key, dynamic value) {
|
||||
String encode(String v) => "$key=${Uri.encodeQueryComponent(v)}";
|
||||
if (value is List<String>) {
|
||||
return value.map(encode).join("&");
|
||||
} else if (value is String) {
|
||||
return encode(value);
|
||||
}
|
||||
|
||||
throw ArgumentError(
|
||||
"Cannot encode value '$value' for key '$key'. Must be 'String' or 'List<String>'",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormDecoder extends Converter<String, Map<String, dynamic>> {
|
||||
// This class may take input as either String or List<int>. If charset is not defined in request,
|
||||
// then data is List<int> (from CodecRegistry) and will default to being UTF8 decoded first.
|
||||
// Otherwise, if String, the request body has been decoded according to charset already.
|
||||
|
||||
const _FormDecoder();
|
||||
|
||||
@override
|
||||
Map<String, dynamic> convert(String data) {
|
||||
return Uri(query: data).queryParametersAll;
|
||||
}
|
||||
|
||||
@override
|
||||
_FormSink startChunkedConversion(Sink<Map<String, dynamic>> outSink) {
|
||||
return _FormSink(outSink);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormSink implements ChunkedConversionSink<String> {
|
||||
_FormSink(this._outSink);
|
||||
|
||||
final _FormDecoder decoder = const _FormDecoder();
|
||||
final Sink<Map<String, dynamic>> _outSink;
|
||||
final StringBuffer _buffer = StringBuffer();
|
||||
|
||||
@override
|
||||
void add(String data) {
|
||||
_buffer.write(data);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_outSink.add(decoder.convert(_buffer.toString()));
|
||||
_outSink.close();
|
||||
}
|
||||
}
|
513
packages/http/lib/src/managed_object_controller.dart
Normal file
513
packages/http/lib/src/managed_object_controller.dart
Normal file
|
@ -0,0 +1,513 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/db.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// A [Controller] that implements basic CRUD operations for a [ManagedObject].
|
||||
///
|
||||
/// Instances of this class map a REST API call
|
||||
/// directly to a database [Query]. For example, this [Controller] handles an HTTP PUT request by executing an update [Query]; the path variable in the request
|
||||
/// indicates the value of the primary key for the updated row and the HTTP request body are the values updated.
|
||||
///
|
||||
/// When routing to a [ManagedObjectController], you must provide the following route pattern, where <name> can be any string:
|
||||
///
|
||||
/// router.route("/<name>/[:id]")
|
||||
///
|
||||
/// You may optionally use the static method [ManagedObjectController.routePattern] to create this string for you.
|
||||
///
|
||||
/// The mapping for HTTP request to action is as follows:
|
||||
///
|
||||
/// - GET /<name>/:id -> Fetch Object by ID
|
||||
/// - PUT /<name>/:id -> Update Object by ID, HTTP Request Body contains update values.
|
||||
/// - DELETE /<name>/:id -> Delete Object by ID
|
||||
/// - POST /<name> -> Create new Object, HTTP Request Body contains update values.
|
||||
/// - GET /<name> -> Fetch instances of Object
|
||||
///
|
||||
/// You may use this class without subclassing, but you may also subclass it to modify the executed [Query] prior to its execution, or modify the returned [Response] after the query has been completed.
|
||||
///
|
||||
/// The HTTP response body is encoded according to [responseContentType].
|
||||
///
|
||||
/// GET requests with no path parameter can take extra query parameters to modify the request. The following are the available query parameters:
|
||||
///
|
||||
/// - count (integer): restricts the number of objects fetched to count. By default, this is null, which means no restrictions.
|
||||
/// - offset (integer): offsets the fetch by offset amount of objects. By default, this is null, which means no offset.
|
||||
/// - pageBy (string): indicates the key in which to page by. See [Query.pageBy] for more information on paging. If this value is passed as part of the query, either pageAfter or pagePrior must also be passed, but only one of those.
|
||||
/// - pageAfter (string): indicates the page value and direction of the paging. pageBy must also be set. See [Query.pageBy] for more information.
|
||||
/// - pagePrior (string): indicates the page value and direction of the paging. pageBy must also be set. See [Query.pageBy] for more information.
|
||||
/// - sortBy (string): indicates the sort order. The syntax is 'sortBy=key,order' where key is a property of [InstanceType] and order is either 'asc' or 'desc'. You may specify multiple sortBy parameters.
|
||||
class ManagedObjectController<InstanceType extends ManagedObject>
|
||||
extends ResourceController {
|
||||
/// Creates an instance of a [ManagedObjectController].
|
||||
ManagedObjectController(ManagedContext context) : super() {
|
||||
_query = Query<InstanceType>(context);
|
||||
}
|
||||
|
||||
/// Creates a new [ManagedObjectController] without a static type.
|
||||
///
|
||||
/// This method is used when generating instances of this type dynamically from runtime values,
|
||||
/// where the static type argument cannot be defined. Behaves just like the unnamed constructor.
|
||||
///
|
||||
ManagedObjectController.forEntity(
|
||||
ManagedEntity entity,
|
||||
ManagedContext context,
|
||||
) : super() {
|
||||
_query = Query.forEntity(entity, context);
|
||||
}
|
||||
|
||||
/// Returns a route pattern for using [ManagedObjectController]s.
|
||||
///
|
||||
/// Returns the string "/$name/[:id]", to be used as a route pattern in a [Router] for instances of [ResourceController] and subclasses.
|
||||
static String routePattern(String name) {
|
||||
return "/$name/[:id]";
|
||||
}
|
||||
|
||||
Query<InstanceType>? _query;
|
||||
|
||||
/// Executed prior to a fetch by ID query.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. The [query] will have a single matcher, where the [InstanceType]'s primary key
|
||||
/// is equal to the first path argument in the [Request]. You may also return a new [Query],
|
||||
/// but it must have the same [InstanceType] as this controller. If you return null from this method, no [Query] will be executed
|
||||
/// and [didNotFindObject] will immediately be called.
|
||||
FutureOr<Query<InstanceType>?> willFindObjectWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after a fetch by ID query that found a matching instance.
|
||||
///
|
||||
/// By default, returns a [Response.ok] with the encoded instance. The [result] is the fetched [InstanceType]. You may override this method
|
||||
/// to provide some other behavior.
|
||||
FutureOr<Response> didFindObject(InstanceType result) {
|
||||
return Response.ok(result);
|
||||
}
|
||||
|
||||
/// Executed after a fetch by ID query that did not find a matching instance.
|
||||
///
|
||||
/// By default, returns [Response.notFound]. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didNotFindObject() {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
@Operation.get("id")
|
||||
Future<Response> getObject(@Bind.path("id") String id) async {
|
||||
final primaryKey = _query!.entity.primaryKey;
|
||||
final parsedIdentifier =
|
||||
_getIdentifierFromPath(id, _query!.entity.properties[primaryKey]);
|
||||
_query!.where((o) => o[primaryKey]).equalTo(parsedIdentifier);
|
||||
|
||||
_query = await willFindObjectWithQuery(_query);
|
||||
|
||||
final InstanceType? result = await _query?.fetchOne();
|
||||
|
||||
if (result == null) {
|
||||
return didNotFindObject();
|
||||
} else {
|
||||
return didFindObject(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed prior to an insert query being executed.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. You may also return a new [Query],
|
||||
/// but it must have the same type argument as this controller. If you return null from this method,
|
||||
/// no values will be inserted and [didInsertObject] will immediately be called with the value null.
|
||||
FutureOr<Query<InstanceType>?> willInsertObjectWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after an insert query is successful.
|
||||
///
|
||||
/// By default, returns [Response.ok]. The [object] is the newly inserted [InstanceType]. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didInsertObject(InstanceType object) {
|
||||
return Response.ok(object);
|
||||
}
|
||||
|
||||
@Operation.post()
|
||||
Future<Response> createObject() async {
|
||||
final instance = _query!.entity.instanceOf() as InstanceType;
|
||||
instance.readFromMap(request!.body.as());
|
||||
_query!.values = instance;
|
||||
|
||||
_query = await willInsertObjectWithQuery(_query);
|
||||
final InstanceType result = (await _query?.insert())!;
|
||||
|
||||
return didInsertObject(result);
|
||||
}
|
||||
|
||||
/// Executed prior to a delete query being executed.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. You may also return a new [Query],
|
||||
/// but it must have the same type argument as this controller. If you return null from this method,
|
||||
/// no delete operation will be performed and [didNotFindObjectToDeleteWithID] will immediately be called with the value null.
|
||||
FutureOr<Query<InstanceType>?> willDeleteObjectWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after an object was deleted.
|
||||
///
|
||||
/// By default, returns [Response.ok] with no response body. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didDeleteObjectWithID(dynamic id) {
|
||||
return Response.ok(null);
|
||||
}
|
||||
|
||||
/// Executed when no object was deleted during a delete query.
|
||||
///
|
||||
/// Defaults to return [Response.notFound]. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didNotFindObjectToDeleteWithID(dynamic id) {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
@Operation.delete("id")
|
||||
Future<Response> deleteObject(@Bind.path("id") String id) async {
|
||||
final primaryKey = _query!.entity.primaryKey;
|
||||
final parsedIdentifier =
|
||||
_getIdentifierFromPath(id, _query!.entity.properties[primaryKey]);
|
||||
_query!.where((o) => o[primaryKey]).equalTo(parsedIdentifier);
|
||||
|
||||
_query = await willDeleteObjectWithQuery(_query);
|
||||
|
||||
final result = await _query?.delete();
|
||||
|
||||
if (result == 0) {
|
||||
return didNotFindObjectToDeleteWithID(id);
|
||||
} else {
|
||||
return didDeleteObjectWithID(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed prior to a update query being executed.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. You may also return a new [Query],
|
||||
/// but it must have the same type argument as this controller. If you return null from this method,
|
||||
/// no values will be inserted and [didNotFindObjectToUpdateWithID] will immediately be called with the value null.
|
||||
FutureOr<Query<InstanceType>?> willUpdateObjectWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after an object was updated.
|
||||
///
|
||||
/// By default, returns [Response.ok] with the encoded, updated object. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didUpdateObject(InstanceType object) {
|
||||
return Response.ok(object);
|
||||
}
|
||||
|
||||
/// Executed after an object not found during an update query.
|
||||
///
|
||||
/// By default, returns [Response.notFound]. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didNotFindObjectToUpdateWithID(dynamic id) {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
@Operation.put("id")
|
||||
Future<Response> updateObject(@Bind.path("id") String id) async {
|
||||
final primaryKey = _query!.entity.primaryKey;
|
||||
final parsedIdentifier =
|
||||
_getIdentifierFromPath(id, _query!.entity.properties[primaryKey]);
|
||||
_query!.where((o) => o[primaryKey]).equalTo(parsedIdentifier);
|
||||
|
||||
final instance = _query!.entity.instanceOf() as InstanceType;
|
||||
instance.readFromMap(request!.body.as());
|
||||
_query!.values = instance;
|
||||
|
||||
_query = await willUpdateObjectWithQuery(_query);
|
||||
|
||||
final InstanceType? results = await _query?.updateOne();
|
||||
if (results == null) {
|
||||
return didNotFindObjectToUpdateWithID(id);
|
||||
} else {
|
||||
return didUpdateObject(results);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed prior to a fetch query being executed.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. You may also return a new [Query],
|
||||
/// but it must have the same type argument as this controller. If you return null from this method,
|
||||
/// no objects will be fetched and [didFindObjects] will immediately be called with the value null.
|
||||
FutureOr<Query<InstanceType>?> willFindObjectsWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after a list of objects has been fetched.
|
||||
///
|
||||
/// By default, returns [Response.ok] with the encoded list of founds objects (which may be the empty list).
|
||||
FutureOr<Response> didFindObjects(List<InstanceType> objects) {
|
||||
return Response.ok(objects);
|
||||
}
|
||||
|
||||
@Operation.get()
|
||||
Future<Response> getObjects({
|
||||
/// Limits the number of objects returned.
|
||||
@Bind.query("count") int count = 0,
|
||||
|
||||
/// An integer offset into an ordered list of objects.
|
||||
///
|
||||
/// Use with count.
|
||||
///
|
||||
/// See pageBy for an alternative form of offsetting.
|
||||
@Bind.query("offset") int offset = 0,
|
||||
|
||||
/// The property of this object to page by.
|
||||
///
|
||||
/// Must be a key in the object type being fetched. Must
|
||||
/// provide either pageAfter or pagePrior. Use with count.
|
||||
@Bind.query("pageBy") String? pageBy,
|
||||
|
||||
/// A value-based offset into an ordered list of objects.
|
||||
///
|
||||
/// Objects are returned if their
|
||||
/// value for the property named by pageBy is greater than
|
||||
/// the value of pageAfter. Must provide pageBy, and the type
|
||||
/// of the property designated by pageBy must be the same as pageAfter.
|
||||
@Bind.query("pageAfter") String? pageAfter,
|
||||
|
||||
/// A value-based offset into an ordered list of objects.
|
||||
///
|
||||
/// Objects are returned if their
|
||||
/// value for the property named by pageBy is less than
|
||||
/// the value of pageAfter. Must provide pageBy, and the type
|
||||
/// of the property designated by pageBy must be the same as pageAfter.
|
||||
@Bind.query("pagePrior") String? pagePrior,
|
||||
|
||||
/// Designates a sorting strategy for the returned objects.
|
||||
///
|
||||
/// This value must take the form 'name,asc' or 'name,desc', where name
|
||||
/// is the property of the returned objects to sort on.
|
||||
@Bind.query("sortBy") List<String>? sortBy,
|
||||
}) async {
|
||||
_query!.fetchLimit = count;
|
||||
_query!.offset = offset;
|
||||
|
||||
if (pageBy != null) {
|
||||
QuerySortOrder direction;
|
||||
String pageValue;
|
||||
if (pageAfter != null) {
|
||||
direction = QuerySortOrder.ascending;
|
||||
pageValue = pageAfter;
|
||||
} else if (pagePrior != null) {
|
||||
direction = QuerySortOrder.descending;
|
||||
pageValue = pagePrior;
|
||||
} else {
|
||||
return Response.badRequest(
|
||||
body: {
|
||||
"error":
|
||||
"missing required parameter 'pageAfter' or 'pagePrior' when 'pageBy' is given"
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final pageByProperty = _query!.entity.properties[pageBy];
|
||||
if (pageByProperty == null) {
|
||||
throw Response.badRequest(body: {"error": "cannot page by '$pageBy'"});
|
||||
}
|
||||
|
||||
final parsed = _parseValueForProperty(pageValue, pageByProperty);
|
||||
_query!.pageBy(
|
||||
(t) => t[pageBy],
|
||||
direction,
|
||||
boundingValue: parsed == "null" ? null : parsed,
|
||||
);
|
||||
}
|
||||
|
||||
if (sortBy != null) {
|
||||
for (final sort in sortBy) {
|
||||
final split = sort.split(",").map((str) => str.trim()).toList();
|
||||
if (split.length != 2) {
|
||||
throw Response.badRequest(
|
||||
body: {
|
||||
"error":
|
||||
"invalid 'sortyBy' format. syntax: 'name,asc' or 'name,desc'."
|
||||
},
|
||||
);
|
||||
}
|
||||
if (_query!.entity.properties[split.first] == null) {
|
||||
throw Response.badRequest(
|
||||
body: {"error": "cannot sort by '$sortBy'"},
|
||||
);
|
||||
}
|
||||
if (split.last != "asc" && split.last != "desc") {
|
||||
throw Response.badRequest(
|
||||
body: {
|
||||
"error":
|
||||
"invalid 'sortBy' format. syntax: 'name,asc' or 'name,desc'."
|
||||
},
|
||||
);
|
||||
}
|
||||
final sortOrder = split.last == "asc"
|
||||
? QuerySortOrder.ascending
|
||||
: QuerySortOrder.descending;
|
||||
_query!.sortBy((t) => t[split.first], sortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
_query = await willFindObjectsWithQuery(_query);
|
||||
|
||||
final results = (await _query?.fetch())!;
|
||||
|
||||
return didFindObjects(results);
|
||||
}
|
||||
|
||||
@override
|
||||
APIRequestBody? documentOperationRequestBody(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
if (operation!.method == "POST" || operation.method == "PUT") {
|
||||
return APIRequestBody.schema(
|
||||
context.schema.getObjectWithType(InstanceType),
|
||||
contentTypes: ["application/json"],
|
||||
isRequired: true,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIResponse> documentOperationResponses(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
switch (operation!.method) {
|
||||
case "GET":
|
||||
if (operation.pathVariables.isEmpty) {
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Returns a list of objects.",
|
||||
APISchemaObject.array(
|
||||
ofSchema: context.schema.getObjectWithType(InstanceType),
|
||||
),
|
||||
),
|
||||
"400": APIResponse.schema(
|
||||
"Invalid request.",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Returns a single object.",
|
||||
context.schema.getObjectWithType(InstanceType),
|
||||
),
|
||||
"404": APIResponse("No object found.")
|
||||
};
|
||||
case "PUT":
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Returns updated object.",
|
||||
context.schema.getObjectWithType(InstanceType),
|
||||
),
|
||||
"404": APIResponse("No object found."),
|
||||
"400": APIResponse.schema(
|
||||
"Invalid request.",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
),
|
||||
"409": APIResponse.schema(
|
||||
"Object already exists",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
),
|
||||
};
|
||||
case "POST":
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Returns created object.",
|
||||
context.schema.getObjectWithType(InstanceType),
|
||||
),
|
||||
"400": APIResponse.schema(
|
||||
"Invalid request.",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
),
|
||||
"409": APIResponse.schema(
|
||||
"Object already exists",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
)
|
||||
};
|
||||
case "DELETE":
|
||||
return {
|
||||
"200": APIResponse("Object successfully deleted."),
|
||||
"404": APIResponse("No object found."),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
final ops = super.documentOperations(context, route, path);
|
||||
|
||||
final entityName = _query!.entity.name;
|
||||
|
||||
if (path.parameters
|
||||
.where((p) => p!.location == APIParameterLocation.path)
|
||||
.isNotEmpty) {
|
||||
ops["get"]!.id = "get$entityName";
|
||||
ops["put"]!.id = "update$entityName";
|
||||
ops["delete"]!.id = "delete$entityName";
|
||||
} else {
|
||||
ops["get"]!.id = "get${entityName}s";
|
||||
ops["post"]!.id = "create$entityName";
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
dynamic _getIdentifierFromPath(
|
||||
String value,
|
||||
ManagedPropertyDescription? desc,
|
||||
) {
|
||||
return _parseValueForProperty(value, desc, onError: Response.notFound());
|
||||
}
|
||||
|
||||
dynamic _parseValueForProperty(
|
||||
String value,
|
||||
ManagedPropertyDescription? desc, {
|
||||
Response? onError,
|
||||
}) {
|
||||
if (value == "null") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (desc!.type!.kind) {
|
||||
case ManagedPropertyType.string:
|
||||
return value;
|
||||
case ManagedPropertyType.bigInteger:
|
||||
return int.parse(value);
|
||||
case ManagedPropertyType.integer:
|
||||
return int.parse(value);
|
||||
case ManagedPropertyType.datetime:
|
||||
return DateTime.parse(value);
|
||||
case ManagedPropertyType.doublePrecision:
|
||||
return double.parse(value);
|
||||
case ManagedPropertyType.boolean:
|
||||
return value == "true";
|
||||
case ManagedPropertyType.list:
|
||||
return null;
|
||||
case ManagedPropertyType.map:
|
||||
return null;
|
||||
case ManagedPropertyType.document:
|
||||
return null;
|
||||
}
|
||||
} on FormatException {
|
||||
throw onError ?? Response.badRequest();
|
||||
}
|
||||
}
|
||||
}
|
72
packages/http/lib/src/query_controller.dart
Normal file
72
packages/http/lib/src/query_controller.dart
Normal file
|
@ -0,0 +1,72 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_database/db.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// A partial class for implementing an [ResourceController] that has a few conveniences
|
||||
/// for executing [Query]s.
|
||||
///
|
||||
/// Instances of [QueryController] are [ResourceController]s that have a pre-baked [Query] available. This [Query]'s type -
|
||||
/// the [ManagedObject] type is operates on - is defined by [InstanceType].
|
||||
///
|
||||
/// The values of [query] are set based on the HTTP method, HTTP path and request body.
|
||||
/// Prior to executing an operation method in subclasses of [QueryController], the [query]
|
||||
/// will have the following attributes under the following conditions:
|
||||
///
|
||||
/// 1. The [Query] will always have a type argument that matches [InstanceType].
|
||||
/// 2. If the request contains a path variable that matches the name of the primary key of [InstanceType], the [Query] will set
|
||||
/// its [Query.where] to match on the [ManagedObject] whose primary key is that value of the path parameter.
|
||||
/// 3. If the [Request] contains a body, it will be decoded per the [acceptedContentTypes] and deserialized into the [Query.values] property via [ManagedObject.readFromMap].
|
||||
abstract class QueryController<InstanceType extends ManagedObject>
|
||||
extends ResourceController {
|
||||
/// Create an instance of [QueryController].
|
||||
QueryController(ManagedContext context) : super() {
|
||||
query = Query<InstanceType>(context);
|
||||
}
|
||||
|
||||
/// A query representing the values received from the [request] being processed.
|
||||
///
|
||||
/// You may execute this [query] as is or modify it. The following is true of this property:
|
||||
///
|
||||
/// 1. The [Query] will always have a type argument that matches [InstanceType].
|
||||
/// 2. If the request contains a path variable that matches the name of the primary key of [InstanceType], the [Query] will set
|
||||
/// its [Query.where] to match on the [ManagedObject] whose primary key is that value of the path parameter.
|
||||
/// 3. If the [Request] contains a body, it will be decoded per the [acceptedContentTypes] and deserialized into the [Query.values] property via [ManagedObject.readFromMap].
|
||||
Query<InstanceType>? query;
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> willProcessRequest(Request req) {
|
||||
if (req.path.orderedVariableNames.isNotEmpty) {
|
||||
final firstVarName = req.path.orderedVariableNames.first;
|
||||
final idValue = req.path.variables[firstVarName];
|
||||
|
||||
if (idValue != null) {
|
||||
final primaryKeyDesc =
|
||||
query!.entity.attributes[query!.entity.primaryKey]!;
|
||||
if (primaryKeyDesc.isAssignableWith(idValue)) {
|
||||
query!.where((o) => o[query!.entity.primaryKey]).equalTo(idValue);
|
||||
} else if (primaryKeyDesc.type!.kind ==
|
||||
ManagedPropertyType.bigInteger ||
|
||||
primaryKeyDesc.type!.kind == ManagedPropertyType.integer) {
|
||||
try {
|
||||
query!
|
||||
.where((o) => o[query!.entity.primaryKey])
|
||||
.equalTo(int.parse(idValue));
|
||||
} on FormatException {
|
||||
return Response.notFound();
|
||||
}
|
||||
} else {
|
||||
return Response.notFound();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.willProcessRequest(req);
|
||||
}
|
||||
|
||||
@override
|
||||
void didDecodeRequestBody(RequestBody body) {
|
||||
query!.values.readFromMap(body.as());
|
||||
query!.values.removePropertyFromBackingMap(query!.values.entity.primaryKey);
|
||||
}
|
||||
}
|
448
packages/http/lib/src/request.dart
Normal file
448
packages/http/lib/src/request.dart
Normal file
|
@ -0,0 +1,448 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// A single HTTP request.
|
||||
///
|
||||
/// Instances of this class travel through a [Controller] chain to be responded to, sometimes acquiring values
|
||||
/// as they go through controllers. Each instance of this class has a standard library [HttpRequest]. You should not respond
|
||||
/// directly to the [HttpRequest], as [Controller]s take that responsibility.
|
||||
class Request implements RequestOrResponse {
|
||||
/// Creates an instance of [Request], no need to do so manually.
|
||||
Request(this.raw)
|
||||
: path = RequestPath(raw.uri.pathSegments),
|
||||
body = RequestBody(raw);
|
||||
|
||||
/// The underlying [HttpRequest] of this instance.
|
||||
///
|
||||
/// Use this property to access values from the HTTP request that aren't accessible through this instance.
|
||||
///
|
||||
/// You should typically not manipulate this property's [HttpRequest.response]. By default, Conduit controls
|
||||
/// the response through its [Controller]s.
|
||||
///
|
||||
/// If you wish to respond to a request manually - and prohibit Conduit from responding to the request - you must
|
||||
/// remove this instance from the request channel. To remove a request from the channel, return null from a [Controller]
|
||||
/// handler method instead of a [Response] or [Request]. For example:
|
||||
///
|
||||
/// router.route("/raw").linkFunction((req) async {
|
||||
/// req.response.statusCode = 200;
|
||||
/// await req.response.close(); // Respond manually to request
|
||||
/// return null; // Take request out of channel; no subsequent controllers will see this request.
|
||||
/// });
|
||||
final HttpRequest raw;
|
||||
|
||||
/// HTTP method of this request.
|
||||
///
|
||||
/// Always uppercase. e.g., GET, POST, PUT.
|
||||
String get method => raw.method.toUpperCase();
|
||||
|
||||
/// The path of the request URI.
|
||||
///
|
||||
/// Provides convenient access to the request URI path. Also provides path variables and wildcard path values
|
||||
/// after this instance is handled by a [Router].
|
||||
final RequestPath path;
|
||||
|
||||
/// The request body object.
|
||||
///
|
||||
/// This object contains the request body if one exists and behavior for decoding it according
|
||||
/// to this instance's content-type. See [RequestBody] for details on decoding the body into
|
||||
/// an object (or objects).
|
||||
///
|
||||
/// This value is is always non-null. If there is no request body, [RequestBody.isEmpty] is true.
|
||||
final RequestBody body;
|
||||
|
||||
/// Information about the client connection.
|
||||
///
|
||||
/// Note: accessing this property incurs a significant performance penalty.
|
||||
HttpConnectionInfo? get connectionInfo => raw.connectionInfo;
|
||||
|
||||
/// The response object of this [Request].
|
||||
///
|
||||
/// Do not write to this value manually. [Controller]s are responsible for
|
||||
/// using a [Response] instance to fill out this property.
|
||||
HttpResponse get response => raw.response;
|
||||
|
||||
/// Authorization information associated with this request.
|
||||
///
|
||||
/// When this request goes through an [Authorizer], this value will be set with
|
||||
/// permission information from the authenticator. Use this to determine client, resource owner
|
||||
/// or other properties of the authentication information in the request. This value will be
|
||||
/// null if no permission has been set.
|
||||
Authorization? authorization;
|
||||
|
||||
List<void Function(Response)>? _responseModifiers;
|
||||
|
||||
/// The acceptable content types for a [Response] returned for this instance.
|
||||
///
|
||||
/// This list is determined by parsing the `Accept` header (or the concatenation
|
||||
/// of multiple `Accept` headers). The list is ordered such the more desirable
|
||||
/// content-types appear earlier in the list. Desirability is determined by
|
||||
/// a q-value (if one exists) and the specificity of the content-type.
|
||||
///
|
||||
/// See also [acceptsContentType].
|
||||
List<ContentType> get acceptableContentTypes {
|
||||
if (_cachedAcceptableTypes == null) {
|
||||
try {
|
||||
final contentTypes = raw.headers[HttpHeaders.acceptHeader]
|
||||
?.expand((h) => h.split(",").map((s) => s.trim()))
|
||||
.where((h) => h.isNotEmpty)
|
||||
.map(ContentType.parse)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
contentTypes.sort((c1, c2) {
|
||||
final num q1 = num.parse(c1.parameters["q"] ?? "1.0");
|
||||
final q2 = num.parse(c2.parameters["q"] ?? "1.0");
|
||||
|
||||
final comparison = q1.compareTo(q2);
|
||||
if (comparison == 0) {
|
||||
if (c1.primaryType == "*" && c2.primaryType != "*") {
|
||||
return 1;
|
||||
} else if (c1.primaryType != "*" && c2.primaryType == "*") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (c1.subType == "*" && c2.subType != "*") {
|
||||
return 1;
|
||||
} else if (c1.subType != "*" && c2.subType == "*") {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -comparison;
|
||||
});
|
||||
|
||||
_cachedAcceptableTypes = contentTypes;
|
||||
} catch (_) {
|
||||
throw Response.badRequest(
|
||||
body: {"error": "accept header is malformed"},
|
||||
);
|
||||
}
|
||||
}
|
||||
return _cachedAcceptableTypes!;
|
||||
}
|
||||
|
||||
List<ContentType>? _cachedAcceptableTypes;
|
||||
|
||||
/// Whether a [Response] may contain a body of type [contentType].
|
||||
///
|
||||
/// This method searches [acceptableContentTypes] for a match with [contentType]. If one exists,
|
||||
/// this method returns true. Otherwise, it returns false.
|
||||
///
|
||||
/// Note that if no Accept header is present, this method always returns true.
|
||||
bool acceptsContentType(ContentType contentType) {
|
||||
if (acceptableContentTypes.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return acceptableContentTypes.any((acceptable) {
|
||||
if (acceptable.primaryType == "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (acceptable.primaryType == contentType.primaryType) {
|
||||
if (acceptable.subType == "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (acceptable.subType == contentType.subType) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether or not this request is a CORS request.
|
||||
///
|
||||
/// This is true if there is an Origin header.
|
||||
bool get isCORSRequest => raw.headers.value("origin") != null;
|
||||
|
||||
/// Whether or not this is a CORS preflight request.
|
||||
///
|
||||
/// This is true if the request HTTP method is OPTIONS and the headers contains Access-Control-Request-Method.
|
||||
bool get isPreflightRequest {
|
||||
return isCORSRequest &&
|
||||
raw.method == "OPTIONS" &&
|
||||
raw.headers.value("access-control-request-method") != null;
|
||||
}
|
||||
|
||||
/// Container for any data a [Controller] wants to attach to this request for the purpose of being used by a later [Controller].
|
||||
///
|
||||
/// Use this property to attach data to a [Request] for use by later [Controller]s.
|
||||
Map<dynamic, dynamic> attachments = {};
|
||||
|
||||
/// The timestamp for when this request was received.
|
||||
DateTime receivedDate = DateTime.now().toUtc();
|
||||
|
||||
/// The timestamp for when this request was responded to.
|
||||
///
|
||||
/// Used for logging.
|
||||
DateTime? respondDate;
|
||||
|
||||
/// Allows a [Controller] to modify the response eventually created for this request, without creating that response itself.
|
||||
///
|
||||
/// Executes [modifier] prior to sending the HTTP response for this request. Modifiers are executed in the order they were added and may contain
|
||||
/// modifiers from other [Controller]s. Modifiers are executed prior to any data encoded or is written to the network socket.
|
||||
///
|
||||
/// This is valuable for middleware that wants to include some information in the response, but some other controller later in the channel
|
||||
/// will create the response. [modifier] will run prior to
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// Future<RequestOrResponse> handle(Request request) async {
|
||||
/// request.addResponseModifier((r) {
|
||||
/// r.headers["x-rate-limit-remaining"] = 200;
|
||||
/// });
|
||||
/// return request;
|
||||
/// }
|
||||
void addResponseModifier(void Function(Response response) modifier) {
|
||||
_responseModifiers ??= [];
|
||||
_responseModifiers!.add(modifier);
|
||||
}
|
||||
|
||||
String get _sanitizedHeaders {
|
||||
final StringBuffer buf = StringBuffer("{");
|
||||
|
||||
raw.headers.forEach((k, v) {
|
||||
buf.write("${_truncatedString(k)} : ${_truncatedString(v.join(","))}\\n");
|
||||
});
|
||||
buf.write("}");
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
String _truncatedString(String originalString, {int charSize = 128}) {
|
||||
if (originalString.length <= charSize) {
|
||||
return originalString;
|
||||
}
|
||||
return "${originalString.substring(0, charSize)} ... (${originalString.length - charSize} truncated bytes)";
|
||||
}
|
||||
|
||||
/// Sends a [Response] to this [Request]'s client.
|
||||
///
|
||||
/// Do not invoke this method directly.
|
||||
///
|
||||
/// [Controller]s invoke this method to respond to this request.
|
||||
///
|
||||
/// Once this method has executed, the [Request] is no longer valid. All headers from [conduitResponse] are
|
||||
/// added to the HTTP response. If [conduitResponse] has a [Response.body], this request will attempt to encode the body data according to the
|
||||
/// Content-Type in the [conduitResponse]'s [Response.headers].
|
||||
///
|
||||
Future respond(Response conduitResponse) {
|
||||
respondDate = DateTime.now().toUtc();
|
||||
|
||||
final modifiers = _responseModifiers;
|
||||
_responseModifiers = null;
|
||||
modifiers?.forEach((modifier) {
|
||||
modifier(conduitResponse);
|
||||
});
|
||||
|
||||
final _Reference<String> compressionType = _Reference(null);
|
||||
var body = conduitResponse.body;
|
||||
if (body is! Stream) {
|
||||
// Note: this pre-encodes the body in memory, such that encoding fails this will throw and we can return a 500
|
||||
// because we have yet to write to the response.
|
||||
body = _responseBodyBytes(conduitResponse, compressionType);
|
||||
}
|
||||
|
||||
response.statusCode = conduitResponse.statusCode!;
|
||||
conduitResponse.headers.forEach((k, v) {
|
||||
response.headers.add(k, v as Object);
|
||||
});
|
||||
|
||||
if (conduitResponse.cachePolicy != null) {
|
||||
response.headers.add(
|
||||
HttpHeaders.cacheControlHeader,
|
||||
conduitResponse.cachePolicy!.headerValue,
|
||||
);
|
||||
}
|
||||
|
||||
if (body == null) {
|
||||
response.headers.removeAll(HttpHeaders.contentTypeHeader);
|
||||
return response.close();
|
||||
}
|
||||
|
||||
response.headers.add(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
conduitResponse.contentType.toString(),
|
||||
);
|
||||
|
||||
if (body is List<int>) {
|
||||
if (compressionType.value != null) {
|
||||
response.headers
|
||||
.add(HttpHeaders.contentEncodingHeader, compressionType.value!);
|
||||
}
|
||||
response.headers.add(HttpHeaders.contentLengthHeader, body.length);
|
||||
|
||||
response.add(body);
|
||||
|
||||
return response.close();
|
||||
} else if (body is Stream) {
|
||||
// Otherwise, body is stream
|
||||
final bodyStream = _responseBodyStream(conduitResponse, compressionType);
|
||||
if (compressionType.value != null) {
|
||||
response.headers
|
||||
.add(HttpHeaders.contentEncodingHeader, compressionType.value!);
|
||||
}
|
||||
response.headers.add(HttpHeaders.transferEncodingHeader, "chunked");
|
||||
response.bufferOutput = conduitResponse.bufferOutput;
|
||||
|
||||
return response.addStream(bodyStream).then((_) {
|
||||
return response.close();
|
||||
}).catchError((e, StackTrace st) {
|
||||
throw HTTPStreamingException(e, st);
|
||||
});
|
||||
}
|
||||
|
||||
throw StateError("Invalid response body. Could not encode.");
|
||||
}
|
||||
|
||||
List<int>? _responseBodyBytes(
|
||||
Response resp,
|
||||
_Reference<String> compressionType,
|
||||
) {
|
||||
if (resp.body == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Codec<dynamic, List<int>>? codec;
|
||||
if (resp.encodeBody) {
|
||||
codec =
|
||||
CodecRegistry.defaultInstance.codecForContentType(resp.contentType);
|
||||
}
|
||||
|
||||
// todo(joeconwaystk): Set minimum threshold on number of bytes needed to perform gzip, do not gzip otherwise.
|
||||
// There isn't a great way of doing this that I can think of except splitting out gzip from the fused codec,
|
||||
// have to measure the value of fusing vs the cost of gzipping smaller data.
|
||||
final canGzip = CodecRegistry.defaultInstance
|
||||
.isContentTypeCompressable(resp.contentType) &&
|
||||
_acceptsGzipResponseBody;
|
||||
|
||||
if (codec == null) {
|
||||
if (resp.body is! List<int>) {
|
||||
throw StateError(
|
||||
"Invalid response body. Body of type '${resp.body.runtimeType}' cannot be encoded as content-type '${resp.contentType}'.",
|
||||
);
|
||||
}
|
||||
|
||||
final bytes = resp.body as List<int>;
|
||||
if (canGzip) {
|
||||
compressionType.value = "gzip";
|
||||
return gzip.encode(bytes);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (canGzip) {
|
||||
compressionType.value = "gzip";
|
||||
codec = codec.fuse(gzip);
|
||||
}
|
||||
|
||||
return codec.encode(resp.body);
|
||||
}
|
||||
|
||||
Stream<List<int>> _responseBodyStream(
|
||||
Response resp,
|
||||
_Reference<String> compressionType,
|
||||
) {
|
||||
Codec<dynamic, List<int>>? codec;
|
||||
if (resp.encodeBody) {
|
||||
codec =
|
||||
CodecRegistry.defaultInstance.codecForContentType(resp.contentType);
|
||||
}
|
||||
|
||||
final canGzip = CodecRegistry.defaultInstance
|
||||
.isContentTypeCompressable(resp.contentType) &&
|
||||
_acceptsGzipResponseBody;
|
||||
if (codec == null) {
|
||||
if (resp.body is! Stream<List<int>>) {
|
||||
throw StateError(
|
||||
"Invalid response body. Body of type '${resp.body.runtimeType}' cannot be encoded as content-type '${resp.contentType}'.",
|
||||
);
|
||||
}
|
||||
|
||||
final stream = resp.body as Stream<List<int>>;
|
||||
if (canGzip) {
|
||||
compressionType.value = "gzip";
|
||||
return gzip.encoder.bind(stream);
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
if (canGzip) {
|
||||
compressionType.value = "gzip";
|
||||
codec = codec.fuse(gzip);
|
||||
}
|
||||
|
||||
return codec.encoder.bind(resp.body as Stream);
|
||||
}
|
||||
|
||||
bool get _acceptsGzipResponseBody {
|
||||
return raw.headers[HttpHeaders.acceptEncodingHeader]
|
||||
?.any((v) => v.split(",").any((s) => s.trim() == "gzip")) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "${raw.method} ${raw.uri} (${receivedDate.millisecondsSinceEpoch})";
|
||||
}
|
||||
|
||||
/// A string that represents more details about the request, typically used for logging.
|
||||
///
|
||||
/// Note: Setting includeRequestIP to true creates a significant performance penalty.
|
||||
String toDebugString({
|
||||
bool includeElapsedTime = true,
|
||||
bool includeRequestIP = false,
|
||||
bool includeMethod = true,
|
||||
bool includeResource = true,
|
||||
bool includeStatusCode = true,
|
||||
bool includeContentSize = false,
|
||||
bool includeHeaders = false,
|
||||
}) {
|
||||
final builder = StringBuffer();
|
||||
if (includeRequestIP) {
|
||||
builder.write("${raw.connectionInfo?.remoteAddress.address} ");
|
||||
}
|
||||
if (includeMethod) {
|
||||
builder.write("${raw.method} ");
|
||||
}
|
||||
if (includeResource) {
|
||||
builder.write("${raw.uri} ");
|
||||
}
|
||||
if (includeElapsedTime && respondDate != null) {
|
||||
builder
|
||||
.write("${respondDate!.difference(receivedDate).inMilliseconds}ms ");
|
||||
}
|
||||
if (includeStatusCode) {
|
||||
builder.write("${raw.response.statusCode} ");
|
||||
}
|
||||
if (includeContentSize) {
|
||||
builder.write("${raw.response.contentLength} ");
|
||||
}
|
||||
if (includeHeaders) {
|
||||
builder.write("$_sanitizedHeaders ");
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class HTTPStreamingException implements Exception {
|
||||
HTTPStreamingException(this.underlyingException, this.trace);
|
||||
|
||||
dynamic underlyingException;
|
||||
StackTrace trace;
|
||||
}
|
||||
|
||||
class _Reference<T> {
|
||||
_Reference(this.value);
|
||||
|
||||
T? value;
|
||||
}
|
105
packages/http/lib/src/request_body.dart
Normal file
105
packages/http/lib/src/request_body.dart
Normal file
|
@ -0,0 +1,105 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Objects that represent a request body, and can be decoded into Dart objects.
|
||||
///
|
||||
/// Every instance of [Request] has a [Request.body] property of this type. Use
|
||||
/// [decode] to convert the contents of this object into a Dart type (e.g, [Map] or [List]).
|
||||
///
|
||||
/// See also [CodecRegistry] for how decoding occurs.
|
||||
class RequestBody extends BodyDecoder {
|
||||
/// Creates a new instance of this type.
|
||||
///
|
||||
/// Instances of this type decode [request]'s body based on its content-type.
|
||||
///
|
||||
/// See [CodecRegistry] for more information about how data is decoded.
|
||||
///
|
||||
/// Decoded data is cached the after it is decoded.
|
||||
RequestBody(HttpRequest super.request)
|
||||
: _request = request,
|
||||
_originalByteStream = request;
|
||||
|
||||
/// The maximum size of a request body.
|
||||
///
|
||||
/// A request with a body larger than this size will be rejected. Value is in bytes. Defaults to 10MB (1024 * 1024 * 10).
|
||||
static int maxSize = 1024 * 1024 * 10;
|
||||
|
||||
final HttpRequest _request;
|
||||
|
||||
bool get _hasContent =>
|
||||
_hasContentLength || _request.headers.chunkedTransferEncoding;
|
||||
|
||||
bool get _hasContentLength => (_request.headers.contentLength) > 0;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get bytes {
|
||||
// If content-length is specified, then we can check it for maxSize
|
||||
// and just return the original stream.
|
||||
if (_hasContentLength) {
|
||||
if (_request.headers.contentLength > maxSize) {
|
||||
throw Response(
|
||||
HttpStatus.requestEntityTooLarge,
|
||||
null,
|
||||
{"error": "entity length exceeds maximum"},
|
||||
);
|
||||
}
|
||||
|
||||
return _originalByteStream;
|
||||
}
|
||||
|
||||
// If content-length is not specified (e.g., chunked),
|
||||
// then we need to check how many bytes we've read to ensure we haven't
|
||||
// crossed maxSize
|
||||
if (_bufferingController == null) {
|
||||
_bufferingController = StreamController<List<int>>(sync: true);
|
||||
|
||||
_originalByteStream.listen(
|
||||
(chunk) {
|
||||
_bytesRead += chunk.length;
|
||||
if (_bytesRead > maxSize) {
|
||||
_bufferingController!.addError(
|
||||
Response(
|
||||
HttpStatus.requestEntityTooLarge,
|
||||
null,
|
||||
{"error": "entity length exceeds maximum"},
|
||||
),
|
||||
);
|
||||
_bufferingController!.close();
|
||||
return;
|
||||
}
|
||||
|
||||
_bufferingController!.add(chunk);
|
||||
},
|
||||
onDone: () {
|
||||
_bufferingController!.close();
|
||||
},
|
||||
onError: (Object e, StackTrace st) {
|
||||
if (!_bufferingController!.isClosed) {
|
||||
_bufferingController!.addError(e, st);
|
||||
_bufferingController!.close();
|
||||
}
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
}
|
||||
|
||||
return _bufferingController!.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
ContentType? get contentType => _request.headers.contentType;
|
||||
|
||||
@override
|
||||
bool get isEmpty => !_hasContent;
|
||||
|
||||
bool get isFormData =>
|
||||
contentType != null &&
|
||||
contentType!.primaryType == "application" &&
|
||||
contentType!.subType == "x-www-form-urlencoded";
|
||||
|
||||
final Stream<List<int>> _originalByteStream;
|
||||
StreamController<List<int>>? _bufferingController;
|
||||
int _bytesRead = 0;
|
||||
}
|
82
packages/http/lib/src/request_path.dart
Normal file
82
packages/http/lib/src/request_path.dart
Normal file
|
@ -0,0 +1,82 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Stores path info for a [Request].
|
||||
///
|
||||
/// Contains the raw path string, the path as segments and values created by routing a request.
|
||||
///
|
||||
/// Note: The properties [variables], [orderedVariableNames] and [remainingPath] are not set until
|
||||
/// after the owning request has passed through a [Router].
|
||||
class RequestPath {
|
||||
/// Default constructor for [RequestPath].
|
||||
///
|
||||
/// There is no need to invoke this constructor manually.
|
||||
RequestPath(this.segments);
|
||||
|
||||
void setSpecification(RouteSpecification spec, {int segmentOffset = 0}) {
|
||||
final requestIterator = segments.iterator;
|
||||
for (var i = 0; i < segmentOffset; i++) {
|
||||
requestIterator.moveNext();
|
||||
}
|
||||
|
||||
for (final segment in spec.segments) {
|
||||
if (!requestIterator.moveNext()) {
|
||||
remainingPath = "";
|
||||
return;
|
||||
}
|
||||
final requestSegment = requestIterator.current;
|
||||
|
||||
if (segment.isVariable) {
|
||||
variables[segment.variableName.toString()] = requestSegment;
|
||||
orderedVariableNames.add(segment.variableName!);
|
||||
} else if (segment.isRemainingMatcher) {
|
||||
final remaining = [];
|
||||
remaining.add(requestIterator.current);
|
||||
while (requestIterator.moveNext()) {
|
||||
remaining.add(requestIterator.current);
|
||||
}
|
||||
remainingPath = remaining.join("/");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Map] of path variables.
|
||||
///
|
||||
/// If a path has variables (indicated by the :variable syntax),
|
||||
/// the matching segments for the path variables will be stored in the map. The key
|
||||
/// will be the variable name (without the colon) and the value will be the
|
||||
/// path segment as a string.
|
||||
///
|
||||
/// Consider a match specification /users/:id. If the evaluated path is
|
||||
/// /users/2
|
||||
/// This property will be {'id' : '2'}.
|
||||
///
|
||||
Map<String, String> variables = {};
|
||||
|
||||
/// A list of the segments in a matched path.
|
||||
///
|
||||
/// This property will contain every segment of the matched path, including
|
||||
/// constant segments. It will not contain any part of the path caught by
|
||||
/// the asterisk 'match all' token (*), however. Those are in [remainingPath].
|
||||
final List<String> segments;
|
||||
|
||||
/// If a match specification uses the 'match all' token (*),
|
||||
/// the part of the path matched by that token will be stored in this property.
|
||||
///
|
||||
/// The remaining path will will be a single string, including any path delimiters (/),
|
||||
/// but will not have a leading path delimiter.
|
||||
String? remainingPath;
|
||||
|
||||
/// An ordered list of variable names (the keys in [variables]) based on their position in the path.
|
||||
///
|
||||
/// If no path variables are present in the request, this list is empty. Only path variables that are
|
||||
/// available for the specific request are in this list. For example, if a route has two path variables,
|
||||
/// but the incoming request this [RequestPath] represents only has one variable, only that one variable
|
||||
/// will appear in this property.
|
||||
List<String> orderedVariableNames = [];
|
||||
|
||||
/// The path of the requested URI.
|
||||
///
|
||||
/// Always contains a leading '/', but never a trailing '/'.
|
||||
String get string => "/${segments.join("/")}";
|
||||
}
|
395
packages/http/lib/src/resource_controller.dart
Executable file
395
packages/http/lib/src/resource_controller.dart
Executable file
|
@ -0,0 +1,395 @@
|
|||
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> {
|
||||
ResourceController() {
|
||||
_runtime =
|
||||
(RuntimeContext.current.runtimes[runtimeType] as ControllerRuntime?)
|
||||
?.resourceController;
|
||||
}
|
||||
|
||||
@override
|
||||
void get recycledState => nullptr;
|
||||
|
||||
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) {}
|
||||
|
||||
@override
|
||||
void restore(void state) {
|
||||
/* no op - fetched from static cache in Runtime */
|
||||
}
|
||||
|
||||
@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];
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
return _runtime!.documenter!.documentOperations(this, context, route, path);
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
_runtime!.documenter?.documentComponents(this, context);
|
||||
}
|
||||
|
||||
bool _requestContentTypeIsSupported(Request? req) {
|
||||
final incomingContentType = request!.raw.headers.contentType;
|
||||
return acceptedContentTypes.firstWhereOrNull((ct) {
|
||||
return ct.primaryType == incomingContentType!.primaryType &&
|
||||
ct.subType == incomingContentType.subType;
|
||||
}) !=
|
||||
null;
|
||||
}
|
||||
|
||||
List<String> _allowedMethodsForPathVariables(
|
||||
Iterable<String?> pathVariables,
|
||||
) {
|
||||
return _runtime!.operations
|
||||
.where((op) => op.isSuitableForRequest(null, pathVariables.toList()))
|
||||
.map((op) => op.httpMethod)
|
||||
.toList();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
251
packages/http/lib/src/resource_controller_bindings.dart
Normal file
251
packages/http/lib/src/resource_controller_bindings.dart
Normal file
|
@ -0,0 +1,251 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Binds an instance method in [ResourceController] to an operation.
|
||||
///
|
||||
/// An operation is a request method (e.g., GET, POST) and a list of path variables. A [ResourceController] implements
|
||||
/// an operation method for each operation it handles (e.g., GET /users/:id, POST /users). A method with this annotation
|
||||
/// will be invoked when a [ResourceController] handles a request where [method] matches the request's method and
|
||||
/// *all* [pathVariables] are present in the request's path. For example:
|
||||
///
|
||||
/// class MyController extends ResourceController {
|
||||
/// @Operation.get('id')
|
||||
/// Future<Response> getOne(@Bind.path('id') int id) async {
|
||||
/// return Response.ok(objects[id]);
|
||||
/// }
|
||||
/// }
|
||||
class Operation {
|
||||
const Operation(
|
||||
this.method, [
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : _pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
const Operation.get([
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : method = "GET",
|
||||
_pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
const Operation.put([
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : method = "PUT",
|
||||
_pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
const Operation.post([
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : method = "POST",
|
||||
_pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
const Operation.delete([
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : method = "DELETE",
|
||||
_pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
final String method;
|
||||
final String? _pathVariable1;
|
||||
final String? _pathVariable2;
|
||||
final String? _pathVariable3;
|
||||
final String? _pathVariable4;
|
||||
|
||||
/// Returns a list of all path variables required for this operation.
|
||||
List<String> get pathVariables {
|
||||
return [_pathVariable1, _pathVariable2, _pathVariable3, _pathVariable4]
|
||||
.fold([], (acc, s) {
|
||||
if (s != null) {
|
||||
acc.add(s);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds elements of an HTTP request to a [ResourceController]'s operation method arguments and properties.
|
||||
///
|
||||
/// See individual constructors and [ResourceController] for more details.
|
||||
class Bind {
|
||||
/// Binds an HTTP query parameter to an [ResourceController] property or operation method argument.
|
||||
///
|
||||
/// When the incoming request's [Uri]
|
||||
/// has a query key that matches [name], the argument or property value is set to the query parameter's value. For example,
|
||||
/// the request /users?foo=bar would bind the value `bar` to the variable `foo`:
|
||||
///
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getUsers(@Bind.query("foo") String foo) async => ...;
|
||||
///
|
||||
/// [name] is compared case-sensitively, i.e. `Foo` and `foo` are different.
|
||||
///
|
||||
/// Note that if the request is a POST with content-type 'application/x-www-form-urlencoded',
|
||||
/// the query string in the request body is bound to arguments with this metadata.
|
||||
///
|
||||
/// Parameters with this metadata may be [String], [bool], or any type that implements `parse` (e.g., [int.parse] or [DateTime.parse]). It may also
|
||||
/// be a [List] of any of the allowed types, for which each query key-value pair in the request [Uri] be available in the list.
|
||||
///
|
||||
/// If the bound parameter is a positional argument in a operation method, it is required for that method. A 400 Bad Request
|
||||
/// will be sent and the operation method will not be invoked if the request does not contain the query key.
|
||||
///
|
||||
/// If the bound parameter is an optional argument in a operation method, it is optional for that method. The value of
|
||||
/// the bound property will be null if it was not present in the request.
|
||||
///
|
||||
/// If the bound parameter is a property without any additional metadata, it is optional for all methods in an [ResourceController].
|
||||
/// If the bound parameter is a property with [requiredBinding], it is required for all methods in an [ResourceController].
|
||||
const Bind.query(this.name)
|
||||
: bindingType = BindingType.query,
|
||||
accept = null,
|
||||
require = null,
|
||||
ignore = null,
|
||||
reject = null;
|
||||
|
||||
/// Binds an HTTP request header to an [ResourceController] property or operation method argument.
|
||||
///
|
||||
/// When the incoming request has a header with the name [name],
|
||||
/// the argument or property is set to the headers's value. For example,
|
||||
/// a request with the header `Authorization: Basic abcdef` would bind the value `Basic abcdef` to the `authHeader` argument:
|
||||
///
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getUsers(@Bind.header("Authorization") String authHeader) async => ...;
|
||||
///
|
||||
/// [name] is compared case-insensitively; both `Authorization` and `authorization` will match the same header.
|
||||
///
|
||||
/// Parameters with this metadata may be [String], [bool], or any type that implements `parse` (e.g., [int.parse] or [DateTime.parse]).
|
||||
///
|
||||
/// If the bound parameter is a positional argument in a operation method, it is required for that method. A 400 Bad Request
|
||||
/// will be sent and the operation method will not be invoked if the request does not contain the header.
|
||||
///
|
||||
/// If the bound parameter is an optional argument in a operation method, it is optional for that method. The value of
|
||||
/// the bound property will be null if it was not present in the request.
|
||||
///
|
||||
/// If the bound parameter is a property without any additional metadata, it is optional for all methods in an [ResourceController].
|
||||
/// If the bound parameter is a property with [requiredBinding], it is required for all methods in an [ResourceController].
|
||||
const Bind.header(this.name)
|
||||
: bindingType = BindingType.header,
|
||||
accept = null,
|
||||
require = null,
|
||||
ignore = null,
|
||||
reject = null;
|
||||
|
||||
/// Binds an HTTP request body to an [ResourceController] property or operation method argument.
|
||||
///
|
||||
/// The body of an incoming
|
||||
/// request is decoded into the bound argument or property. The argument or property *must* implement [Serializable] or be
|
||||
/// a [List<Serializable>]. If the property or argument is a [List<Serializable>], the request body must be able to be decoded into
|
||||
/// a [List] of objects (i.e., a JSON array) and [Serializable.read] is invoked for each object (see this method for parameter details).
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
///
|
||||
/// class UserController extends ResourceController {
|
||||
/// @Operation.post()
|
||||
/// Future<Response> createUser(@Bind.body() User user) async {
|
||||
/// final username = user.name;
|
||||
/// ...
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
///
|
||||
/// If the bound parameter is a positional argument in a operation method, it is required for that method.
|
||||
/// If the bound parameter is an optional argument in a operation method, it is optional for that method.
|
||||
/// If the bound parameter is a property without any additional metadata, it is optional for all methods in an [ResourceController].
|
||||
/// If the bound parameter is a property with [requiredBinding], it is required for all methods in an [ResourceController].
|
||||
///
|
||||
/// Requirements that are not met will be throw a 400 Bad Request response with the name of the missing header in the JSON error body.
|
||||
/// No operation method will be called in this case.
|
||||
///
|
||||
/// If not required and not present in a request, the bound arguments and properties will be null when the operation method is invoked.
|
||||
const Bind.body({this.accept, this.ignore, this.reject, this.require})
|
||||
: name = null,
|
||||
bindingType = BindingType.body;
|
||||
|
||||
/// Binds a route variable from [RequestPath.variables] to an [ResourceController] operation method argument.
|
||||
///
|
||||
/// Routes may have path variables, e.g., a route declared as follows has an optional path variable named 'id':
|
||||
///
|
||||
/// router.route("/users/[:id]");
|
||||
///
|
||||
/// A operation
|
||||
/// method is invoked if it has exactly the same path bindings as the incoming request's path variables. For example,
|
||||
/// consider the above route and a controller with the following operation methods:
|
||||
///
|
||||
/// class UserController extends ResourceController {
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getUsers() async => Response.ok(getAllUsers());
|
||||
/// @Operation.get('id')
|
||||
/// Future<Response> getOneUser(@Bind.path("id") int id) async => Response.ok(getUser(id));
|
||||
/// }
|
||||
///
|
||||
/// If the request path is /users/1, /users/2, etc., `getOneUser` is invoked because the path variable `id` is present and matches
|
||||
/// the [Bind.path] argument. If no path variables are present, `getUsers` is invoked.
|
||||
const Bind.path(this.name)
|
||||
: bindingType = BindingType.path,
|
||||
accept = null,
|
||||
require = null,
|
||||
ignore = null,
|
||||
reject = null;
|
||||
|
||||
final String? name;
|
||||
final BindingType bindingType;
|
||||
|
||||
final List<String>? accept;
|
||||
final List<String>? ignore;
|
||||
final List<String>? reject;
|
||||
final List<String>? require;
|
||||
}
|
||||
|
||||
enum BindingType { query, header, body, path }
|
||||
|
||||
/// Marks an [ResourceController] property binding as required.
|
||||
///
|
||||
/// Bindings are often applied to operation method arguments, in which required vs. optional
|
||||
/// is determined by whether or not the argument is in required or optional in the method signature.
|
||||
///
|
||||
/// When properties are bound, they are optional by default. Adding this metadata to a bound controller
|
||||
/// property requires that it for all operation methods.
|
||||
///
|
||||
/// For example, the following controller requires the header 'X-Request-ID' for both of its operation methods:
|
||||
///
|
||||
/// class UserController extends ResourceController {
|
||||
/// @requiredBinding
|
||||
/// @Bind.header("x-request-id")
|
||||
/// String requestID;
|
||||
///
|
||||
/// @Operation.get('id')
|
||||
/// Future<Response> getUser(@Bind.path("id") int id) async
|
||||
/// => return Response.ok(await getUserByID(id));
|
||||
///
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getAllUsers() async
|
||||
/// => return Response.ok(await getUsers());
|
||||
/// }
|
||||
const RequiredBinding requiredBinding = RequiredBinding();
|
||||
|
||||
/// See [requiredBinding].
|
||||
class RequiredBinding {
|
||||
const RequiredBinding();
|
||||
}
|
227
packages/http/lib/src/resource_controller_interfaces.dart
Executable file
227
packages/http/lib/src/resource_controller_interfaces.dart
Executable file
|
@ -0,0 +1,227 @@
|
|||
import 'dart:async';
|
||||
|
||||
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';
|
||||
|
||||
abstract class ResourceControllerRuntime {
|
||||
List<ResourceControllerParameter>? ivarParameters;
|
||||
late List<ResourceControllerOperation> operations;
|
||||
|
||||
ResourceControllerDocumenter? documenter;
|
||||
|
||||
ResourceControllerOperation? getOperationRuntime(
|
||||
String method,
|
||||
List<String?> pathVariables,
|
||||
) {
|
||||
return operations.firstWhereOrNull(
|
||||
(op) => op.isSuitableForRequest(method, pathVariables),
|
||||
);
|
||||
}
|
||||
|
||||
void applyRequestProperties(
|
||||
ResourceController untypedController,
|
||||
ResourceControllerOperationInvocationArgs args,
|
||||
);
|
||||
}
|
||||
|
||||
abstract class ResourceControllerDocumenter {
|
||||
void documentComponents(ResourceController rc, APIDocumentContext context);
|
||||
|
||||
List<APIParameter> documentOperationParameters(
|
||||
ResourceController rc,
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
);
|
||||
|
||||
APIRequestBody? documentOperationRequestBody(
|
||||
ResourceController rc,
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
);
|
||||
|
||||
Map<String, APIOperation> documentOperations(
|
||||
ResourceController rc,
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
);
|
||||
}
|
||||
|
||||
class ResourceControllerOperation {
|
||||
ResourceControllerOperation({
|
||||
required this.scopes,
|
||||
required this.pathVariables,
|
||||
required this.httpMethod,
|
||||
required this.dartMethodName,
|
||||
required this.positionalParameters,
|
||||
required this.namedParameters,
|
||||
required this.invoker,
|
||||
});
|
||||
|
||||
final List<AuthScope>? scopes;
|
||||
final List<String> pathVariables;
|
||||
final String httpMethod;
|
||||
final String dartMethodName;
|
||||
|
||||
final List<ResourceControllerParameter> positionalParameters;
|
||||
final List<ResourceControllerParameter> namedParameters;
|
||||
|
||||
final Future<Response> Function(
|
||||
ResourceController resourceController,
|
||||
ResourceControllerOperationInvocationArgs args,
|
||||
) invoker;
|
||||
|
||||
/// Checks if a request's method and path variables will select this binder.
|
||||
///
|
||||
/// Note that [requestMethod] may be null; if this is the case, only
|
||||
/// path variables are compared.
|
||||
bool isSuitableForRequest(
|
||||
String? requestMethod,
|
||||
List<String?> requestPathVariables,
|
||||
) {
|
||||
if (requestMethod != null && requestMethod.toUpperCase() != httpMethod) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathVariables.length != requestPathVariables.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return requestPathVariables.every(pathVariables.contains);
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceControllerParameter {
|
||||
ResourceControllerParameter({
|
||||
required this.symbolName,
|
||||
required this.name,
|
||||
required this.location,
|
||||
required this.isRequired,
|
||||
required dynamic Function(dynamic input)? decoder,
|
||||
required this.type,
|
||||
required this.defaultValue,
|
||||
required this.acceptFilter,
|
||||
required this.ignoreFilter,
|
||||
required this.requireFilter,
|
||||
required this.rejectFilter,
|
||||
}) : _decoder = decoder;
|
||||
|
||||
static ResourceControllerParameter make<T>({
|
||||
required String symbolName,
|
||||
required String? name,
|
||||
required BindingType location,
|
||||
required bool isRequired,
|
||||
required dynamic Function(dynamic input) decoder,
|
||||
required dynamic defaultValue,
|
||||
required List<String>? acceptFilter,
|
||||
required List<String>? ignoreFilter,
|
||||
required List<String>? requireFilter,
|
||||
required List<String>? rejectFilter,
|
||||
}) {
|
||||
return ResourceControllerParameter(
|
||||
symbolName: symbolName,
|
||||
name: name,
|
||||
location: location,
|
||||
isRequired: isRequired,
|
||||
decoder: decoder,
|
||||
type: T,
|
||||
defaultValue: defaultValue,
|
||||
acceptFilter: acceptFilter,
|
||||
ignoreFilter: ignoreFilter,
|
||||
requireFilter: requireFilter,
|
||||
rejectFilter: rejectFilter,
|
||||
);
|
||||
}
|
||||
|
||||
final String symbolName;
|
||||
final String? name;
|
||||
final Type type;
|
||||
final dynamic defaultValue;
|
||||
final List<String>? acceptFilter;
|
||||
final List<String>? ignoreFilter;
|
||||
final List<String>? requireFilter;
|
||||
final List<String>? rejectFilter;
|
||||
|
||||
/// The location in the request that this parameter is bound to
|
||||
final BindingType location;
|
||||
|
||||
final bool isRequired;
|
||||
|
||||
final dynamic Function(dynamic input)? _decoder;
|
||||
|
||||
APIParameterLocation get apiLocation {
|
||||
switch (location) {
|
||||
case BindingType.body:
|
||||
throw StateError('body parameters do not have a location');
|
||||
case BindingType.header:
|
||||
return APIParameterLocation.header;
|
||||
case BindingType.query:
|
||||
return APIParameterLocation.query;
|
||||
case BindingType.path:
|
||||
return APIParameterLocation.path;
|
||||
}
|
||||
}
|
||||
|
||||
String get locationName {
|
||||
switch (location) {
|
||||
case BindingType.query:
|
||||
return "query";
|
||||
case BindingType.body:
|
||||
return "body";
|
||||
case BindingType.header:
|
||||
return "header";
|
||||
case BindingType.path:
|
||||
return "path";
|
||||
}
|
||||
}
|
||||
|
||||
dynamic decode(Request? request) {
|
||||
switch (location) {
|
||||
case BindingType.query:
|
||||
{
|
||||
final queryParameters = request!.raw.uri.queryParametersAll;
|
||||
final value = request.body.isFormData
|
||||
? request.body.as<Map<String, List<String>>>()[name!]
|
||||
: queryParameters[name!];
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return _decoder!(value);
|
||||
}
|
||||
|
||||
case BindingType.body:
|
||||
{
|
||||
if (request!.body.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _decoder!(request.body);
|
||||
}
|
||||
case BindingType.header:
|
||||
{
|
||||
final header = request!.raw.headers[name!];
|
||||
if (header == null) {
|
||||
return null;
|
||||
}
|
||||
return _decoder!(header);
|
||||
}
|
||||
|
||||
case BindingType.path:
|
||||
{
|
||||
final path = request!.path.variables[name];
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
return _decoder!(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceControllerOperationInvocationArgs {
|
||||
late Map<String, dynamic> instanceVariables;
|
||||
late Map<String, dynamic> namedArguments;
|
||||
late List<dynamic> positionalArguments;
|
||||
}
|
43
packages/http/lib/src/resource_controller_scope.dart
Normal file
43
packages/http/lib/src/resource_controller_scope.dart
Normal file
|
@ -0,0 +1,43 @@
|
|||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Allows [ResourceController]s to have different scope for each operation method.
|
||||
///
|
||||
/// This type is used as an annotation to an operation method declared in a [ResourceController].
|
||||
///
|
||||
/// If an operation method has this annotation, an incoming [Request.authorization] must have sufficient
|
||||
/// scope for the method to be executed. If not, a 403 Forbidden response is sent. Sufficient scope
|
||||
/// requires that *every* listed scope is met by the request.
|
||||
///
|
||||
/// The typical use case is to require more scope for an editing action than a viewing action. Example:
|
||||
///
|
||||
/// class NoteController extends ResourceController {
|
||||
/// @Scope(['notes.readonly']);
|
||||
/// @Operation.get('id')
|
||||
/// Future<Response> getNote(@Bind.path('id') int id) async {
|
||||
/// ...
|
||||
/// }
|
||||
///
|
||||
/// @Scope(['notes']);
|
||||
/// @Operation.post()
|
||||
/// Future<Response> createNote() async {
|
||||
/// ...
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// An [Authorizer] *must* have been previously linked in the channel. Otherwise, an error is thrown
|
||||
/// at runtime. Example:
|
||||
///
|
||||
/// router
|
||||
/// .route("/notes/[:id]")
|
||||
/// .link(() => Authorizer.bearer(authServer))
|
||||
/// .link(() => NoteController());
|
||||
class Scope {
|
||||
/// Add to [ResourceController] operation method to require authorization scope.
|
||||
///
|
||||
/// An incoming [Request.authorization] must have sufficient scope for all [scopes].
|
||||
const Scope(this.scopes);
|
||||
|
||||
/// The list of authorization scopes required.
|
||||
final List<String> scopes;
|
||||
}
|
225
packages/http/lib/src/response.dart
Normal file
225
packages/http/lib/src/response.dart
Normal file
|
@ -0,0 +1,225 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Represents the information in an HTTP response.
|
||||
///
|
||||
/// This object can be used to write an HTTP response and contains conveniences
|
||||
/// for creating these objects.
|
||||
class Response implements RequestOrResponse {
|
||||
/// The default constructor.
|
||||
///
|
||||
/// There exist convenience constructors for common response status codes
|
||||
/// and you should prefer to use those.
|
||||
Response(int this.statusCode, Map<String, dynamic>? headers, dynamic body) {
|
||||
this.body = body;
|
||||
this.headers = LinkedHashMap<String, dynamic>(
|
||||
equals: (a, b) => a.toLowerCase() == b.toLowerCase(),
|
||||
hashCode: (key) => key.toLowerCase().hashCode);
|
||||
this.headers.addAll(headers ?? {});
|
||||
}
|
||||
|
||||
/// Represents a 200 response.
|
||||
Response.ok(dynamic body, {Map<String, dynamic>? headers})
|
||||
: this(HttpStatus.ok, headers, body);
|
||||
|
||||
/// Represents a 201 response.
|
||||
///
|
||||
/// The [location] is a URI that is added as the Location header.
|
||||
Response.created(
|
||||
String location, {
|
||||
dynamic body,
|
||||
Map<String, dynamic>? headers,
|
||||
}) : this(
|
||||
HttpStatus.created,
|
||||
_headersWith(headers, {HttpHeaders.locationHeader: location}),
|
||||
body,
|
||||
);
|
||||
|
||||
/// Represents a 202 response.
|
||||
Response.accepted({Map<String, dynamic>? headers})
|
||||
: this(HttpStatus.accepted, headers, null);
|
||||
|
||||
/// Represents a 204 response.
|
||||
Response.noContent({Map<String, dynamic>? headers})
|
||||
: this(HttpStatus.noContent, headers, null);
|
||||
|
||||
/// Represents a 304 response.
|
||||
///
|
||||
/// Where [lastModified] is the last modified date of the resource
|
||||
/// and [cachePolicy] is the same policy as applied when this resource was first fetched.
|
||||
Response.notModified(DateTime lastModified, this.cachePolicy) {
|
||||
statusCode = HttpStatus.notModified;
|
||||
headers = {HttpHeaders.lastModifiedHeader: HttpDate.format(lastModified)};
|
||||
}
|
||||
|
||||
/// Represents a 400 response.
|
||||
Response.badRequest({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.badRequest, headers, body);
|
||||
|
||||
/// Represents a 401 response.
|
||||
Response.unauthorized({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.unauthorized, headers, body);
|
||||
|
||||
/// Represents a 403 response.
|
||||
Response.forbidden({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.forbidden, headers, body);
|
||||
|
||||
/// Represents a 404 response.
|
||||
Response.notFound({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.notFound, headers, body);
|
||||
|
||||
/// Represents a 409 response.
|
||||
Response.conflict({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.conflict, headers, body);
|
||||
|
||||
/// Represents a 410 response.
|
||||
Response.gone({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.gone, headers, body);
|
||||
|
||||
/// Represents a 500 response.
|
||||
Response.serverError({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.internalServerError, headers, body);
|
||||
|
||||
/// The default value of a [contentType].
|
||||
///
|
||||
/// If no [contentType] is set for an instance, this is the value used. By default, this value is
|
||||
/// [ContentType.json].
|
||||
static ContentType defaultContentType = ContentType.json;
|
||||
|
||||
/// An object representing the body of the [Response], which will be encoded when used to [Request.respond].
|
||||
///
|
||||
/// This is typically a map or list of maps that will be encoded to JSON. If the [body] was previously set with a [Serializable] object
|
||||
/// or a list of [Serializable] objects, this property will be the already serialized (but not encoded) body.
|
||||
dynamic get body => _body;
|
||||
|
||||
/// Sets the unencoded response body.
|
||||
///
|
||||
/// This may be any value that can be encoded into an HTTP response body. If this value is a [Serializable] or a [List] of [Serializable],
|
||||
/// each instance of [Serializable] will transformed via its [Serializable.asMap] method before being set.
|
||||
set body(dynamic initialResponseBody) {
|
||||
dynamic serializedBody;
|
||||
if (initialResponseBody is Serializable) {
|
||||
serializedBody = initialResponseBody.asMap();
|
||||
} else if (initialResponseBody is List<Serializable>) {
|
||||
serializedBody =
|
||||
initialResponseBody.map((value) => value.asMap()).toList();
|
||||
}
|
||||
|
||||
_body = serializedBody ?? initialResponseBody;
|
||||
}
|
||||
|
||||
dynamic _body;
|
||||
|
||||
/// Whether or not this instance should buffer its output or send it right away.
|
||||
///
|
||||
/// In general, output should be buffered and therefore this value defaults to 'true'.
|
||||
///
|
||||
/// For long-running requests where data may be made available over time,
|
||||
/// this value can be set to 'false' to emit bytes to the HTTP client
|
||||
/// as they are provided.
|
||||
///
|
||||
/// This property has no effect if [body] is not a [Stream].
|
||||
bool bufferOutput = true;
|
||||
|
||||
/// Map of headers to send in this response.
|
||||
///
|
||||
/// Where the key is the Header name and value is the Header value. Values are added to the Response body
|
||||
/// according to [HttpHeaders.add].
|
||||
///
|
||||
/// The keys of this map are case-insensitive - they will always be lowercased. If the value is a [List],
|
||||
/// each item in the list will be added separately for the same header name.
|
||||
///
|
||||
/// See [contentType] for behavior when setting 'content-type' in this property.
|
||||
Map<String, dynamic> get headers => _headers;
|
||||
set headers(Map<String, dynamic> h) {
|
||||
_headers.clear();
|
||||
_headers.addAll(h);
|
||||
}
|
||||
|
||||
final Map<String, dynamic> _headers = LinkedHashMap<String, Object?>(
|
||||
equals: (a, b) => a.toLowerCase() == b.toLowerCase(),
|
||||
hashCode: (key) => key.toLowerCase().hashCode);
|
||||
|
||||
/// The HTTP status code of this response.
|
||||
int? statusCode;
|
||||
|
||||
/// Cache policy that sets 'Cache-Control' headers for this instance.
|
||||
///
|
||||
/// If null (the default), no 'Cache-Control' headers are applied. Otherwise,
|
||||
/// the value returned by [CachePolicy.headerValue] will be applied to this instance for the header name
|
||||
/// 'Cache-Control'.
|
||||
CachePolicy? cachePolicy;
|
||||
|
||||
/// The content type of the body of this response.
|
||||
///
|
||||
/// Defaults to [defaultContentType]. This response's body will be encoded according to this value.
|
||||
/// The Content-Type header of the HTTP response will always be set according to this value.
|
||||
///
|
||||
/// If this value is set directly, then this instance's Content-Type will be that value.
|
||||
/// If this value is not set, then the [headers] property is checked for the key 'content-type'.
|
||||
/// If the key is not present in [headers], this property's value is [defaultContentType].
|
||||
///
|
||||
/// If the key is present and the value is a [String], this value is the result of passing the value to [ContentType.parse].
|
||||
/// If the key is present and the value is a [ContentType], this property is equal to that value.
|
||||
ContentType? get contentType {
|
||||
if (_contentType != null) {
|
||||
return _contentType;
|
||||
}
|
||||
|
||||
final inHeaders = _headers[HttpHeaders.contentTypeHeader];
|
||||
if (inHeaders == null) {
|
||||
return defaultContentType;
|
||||
}
|
||||
|
||||
if (inHeaders is ContentType) {
|
||||
return inHeaders;
|
||||
}
|
||||
|
||||
if (inHeaders is String) {
|
||||
return ContentType.parse(inHeaders);
|
||||
}
|
||||
|
||||
throw StateError(
|
||||
"Invalid content-type response header. Is not 'String' or 'ContentType'.",
|
||||
);
|
||||
}
|
||||
|
||||
set contentType(ContentType? t) {
|
||||
_contentType = t;
|
||||
}
|
||||
|
||||
ContentType? _contentType;
|
||||
|
||||
/// Whether or nor this instance has explicitly has its [contentType] property.
|
||||
///
|
||||
/// This value indicates whether or not [contentType] has been set, or is still using its default value.
|
||||
bool get hasExplicitlySetContentType => _contentType != null;
|
||||
|
||||
/// Whether or not the body object of this instance should be encoded.
|
||||
///
|
||||
/// By default, a body object is encoded according to its [contentType] and the corresponding
|
||||
/// [Codec] in [CodecRegistry].
|
||||
///
|
||||
/// If this instance's body object has already been encoded as a list of bytes by some other mechanism,
|
||||
/// this property should be set to false to avoid the encoding process. This is useful when streaming a file
|
||||
/// from disk where it is already stored as an encoded list of bytes.
|
||||
bool encodeBody = true;
|
||||
|
||||
static Map<String, dynamic> _headersWith(
|
||||
Map<String, dynamic>? inputHeaders,
|
||||
Map<String, dynamic> otherHeaders,
|
||||
) {
|
||||
final m = LinkedHashMap<String, Object?>(
|
||||
equals: (a, b) => a.toLowerCase() == b.toLowerCase(),
|
||||
hashCode: (key) => key.toLowerCase().hashCode);
|
||||
if (inputHeaders != null) {
|
||||
m.addAll(inputHeaders);
|
||||
}
|
||||
m.addAll(otherHeaders);
|
||||
return m;
|
||||
}
|
||||
}
|
223
packages/http/lib/src/route_node.dart
Normal file
223
packages/http/lib/src/route_node.dart
Normal file
|
@ -0,0 +1,223 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
class RouteSegment {
|
||||
RouteSegment(String segment) {
|
||||
if (segment == "*") {
|
||||
isRemainingMatcher = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final regexIndex = segment.indexOf("(");
|
||||
if (regexIndex != -1) {
|
||||
final regexText = segment.substring(regexIndex + 1, segment.length - 1);
|
||||
matcher = RegExp(regexText);
|
||||
|
||||
segment = segment.substring(0, regexIndex);
|
||||
}
|
||||
|
||||
if (segment.startsWith(":")) {
|
||||
variableName = segment.substring(1, segment.length);
|
||||
} else if (regexIndex == -1) {
|
||||
literal = segment;
|
||||
}
|
||||
}
|
||||
|
||||
RouteSegment.direct({
|
||||
this.literal,
|
||||
this.variableName,
|
||||
String? expression,
|
||||
bool matchesAnything = false,
|
||||
}) {
|
||||
isRemainingMatcher = matchesAnything;
|
||||
if (expression != null) {
|
||||
matcher = RegExp(expression);
|
||||
}
|
||||
}
|
||||
|
||||
String? literal;
|
||||
String? variableName;
|
||||
RegExp? matcher;
|
||||
|
||||
bool get isLiteralMatcher =>
|
||||
!isRemainingMatcher && !isVariable && !hasRegularExpression;
|
||||
|
||||
bool get hasRegularExpression => matcher != null;
|
||||
|
||||
bool get isVariable => variableName != null;
|
||||
bool isRemainingMatcher = false;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is RouteSegment &&
|
||||
literal == other.literal &&
|
||||
variableName == other.variableName &&
|
||||
isRemainingMatcher == other.isRemainingMatcher &&
|
||||
matcher?.pattern == other.matcher?.pattern;
|
||||
|
||||
@override
|
||||
int get hashCode => (literal ?? variableName).hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (isLiteralMatcher) {
|
||||
return literal ?? "";
|
||||
}
|
||||
|
||||
if (isVariable) {
|
||||
return variableName ?? "";
|
||||
}
|
||||
|
||||
if (hasRegularExpression) {
|
||||
return "(${matcher!.pattern})";
|
||||
}
|
||||
|
||||
return "*";
|
||||
}
|
||||
}
|
||||
|
||||
class RouteNode {
|
||||
RouteNode(List<RouteSpecification?> specs, {int depth = 0, RegExp? matcher}) {
|
||||
patternMatcher = matcher;
|
||||
|
||||
final terminatedAtThisDepth =
|
||||
specs.where((spec) => spec?.segments.length == depth).toList();
|
||||
if (terminatedAtThisDepth.length > 1) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Cannot disambiguate from the following routes: $terminatedAtThisDepth.",
|
||||
);
|
||||
} else if (terminatedAtThisDepth.length == 1) {
|
||||
specification = terminatedAtThisDepth.first;
|
||||
}
|
||||
|
||||
final remainingSpecifications = List<RouteSpecification?>.from(
|
||||
specs.where((spec) => depth != spec?.segments.length),
|
||||
);
|
||||
|
||||
final Set<String> childEqualitySegments = Set.from(
|
||||
remainingSpecifications
|
||||
.where((spec) => spec?.segments[depth].isLiteralMatcher ?? false)
|
||||
.map((spec) => spec!.segments[depth].literal),
|
||||
);
|
||||
|
||||
for (final childSegment in childEqualitySegments) {
|
||||
final childrenBeginningWithThisSegment = remainingSpecifications
|
||||
.where((spec) => spec?.segments[depth].literal == childSegment)
|
||||
.toList();
|
||||
equalityChildren[childSegment] =
|
||||
RouteNode(childrenBeginningWithThisSegment, depth: depth + 1);
|
||||
remainingSpecifications
|
||||
.removeWhere(childrenBeginningWithThisSegment.contains);
|
||||
}
|
||||
|
||||
final takeAllSegment = remainingSpecifications.firstWhere(
|
||||
(spec) => spec?.segments[depth].isRemainingMatcher ?? false,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (takeAllSegment != null) {
|
||||
takeAllChild = RouteNode.withSpecification(takeAllSegment);
|
||||
remainingSpecifications.removeWhere(
|
||||
(spec) => spec?.segments[depth].isRemainingMatcher ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
final Set<String?> childPatternedSegments = Set.from(
|
||||
remainingSpecifications
|
||||
.map((spec) => spec?.segments[depth].matcher?.pattern),
|
||||
);
|
||||
|
||||
patternedChildren = childPatternedSegments.map((pattern) {
|
||||
final childrenWithThisPattern = remainingSpecifications
|
||||
.where((spec) => spec?.segments[depth].matcher?.pattern == pattern)
|
||||
.toList();
|
||||
|
||||
if (childrenWithThisPattern
|
||||
.any((spec) => spec?.segments[depth].matcher == null) &&
|
||||
childrenWithThisPattern
|
||||
.any((spec) => spec?.segments[depth].matcher != null)) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Cannot disambiguate from the following routes, as one of them will match anything: $childrenWithThisPattern.",
|
||||
);
|
||||
}
|
||||
|
||||
return RouteNode(
|
||||
childrenWithThisPattern,
|
||||
depth: depth + 1,
|
||||
matcher: childrenWithThisPattern.first?.segments[depth].matcher,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
RouteNode.withSpecification(this.specification);
|
||||
|
||||
// Regular expression matcher for this node. May be null.
|
||||
RegExp? patternMatcher;
|
||||
Controller? get controller => specification?.controller;
|
||||
RouteSpecification? specification;
|
||||
|
||||
// Includes children that are variables with and without regex patterns
|
||||
List<RouteNode> patternedChildren = [];
|
||||
|
||||
// Includes children that are literal path segments that can be matched with simple string equality
|
||||
Map<String, RouteNode> equalityChildren = {};
|
||||
|
||||
// Valid if has child that is a take all (*) segment.
|
||||
RouteNode? takeAllChild;
|
||||
|
||||
RouteNode? nodeForPathSegments(
|
||||
Iterator<String> requestSegments,
|
||||
RequestPath path,
|
||||
) {
|
||||
if (!requestSegments.moveNext()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
final nextSegment = requestSegments.current;
|
||||
|
||||
if (equalityChildren.containsKey(nextSegment)) {
|
||||
return equalityChildren[nextSegment]!
|
||||
.nodeForPathSegments(requestSegments, path);
|
||||
}
|
||||
|
||||
for (final node in patternedChildren) {
|
||||
if (node.patternMatcher == null) {
|
||||
// This is a variable with no regular expression
|
||||
return node.nodeForPathSegments(requestSegments, path);
|
||||
}
|
||||
|
||||
if (node.patternMatcher!.firstMatch(nextSegment) != null) {
|
||||
// This segment has a regular expression
|
||||
return node.nodeForPathSegments(requestSegments, path);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is null, then we return null from this method
|
||||
// and the router knows we didn't find a match.
|
||||
return takeAllChild;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString({int depth = 0}) {
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < depth; i++) {
|
||||
buf.write("\t");
|
||||
}
|
||||
|
||||
if (patternMatcher != null) {
|
||||
buf.write("(match: ${patternMatcher!.pattern})");
|
||||
}
|
||||
|
||||
buf.writeln(
|
||||
"Controller: ${specification?.controller?.nextController?.runtimeType}",
|
||||
);
|
||||
equalityChildren.forEach((seg, spec) {
|
||||
for (var i = 0; i < depth; i++) {
|
||||
buf.write("\t");
|
||||
}
|
||||
|
||||
buf.writeln("/$seg");
|
||||
buf.writeln(spec.toString(depth: depth + 1));
|
||||
});
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
}
|
157
packages/http/lib/src/route_specification.dart
Normal file
157
packages/http/lib/src/route_specification.dart
Normal file
|
@ -0,0 +1,157 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Specifies a matchable route path.
|
||||
///
|
||||
/// Contains [RouteSegment]s for each path segment. This class is used internally by [Router].
|
||||
class RouteSpecification {
|
||||
/// Creates a [RouteSpecification] from a [String].
|
||||
///
|
||||
/// The [patternString] must be stripped of any optionals.
|
||||
RouteSpecification(String patternString) {
|
||||
segments = _splitPathSegments(patternString);
|
||||
variableNames = segments
|
||||
.where((e) => e.isVariable)
|
||||
.map((e) => e.variableName!)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<RouteSpecification> specificationsForRoutePattern(
|
||||
String routePattern,
|
||||
) {
|
||||
return _pathsFromRoutePattern(routePattern)
|
||||
.map((path) => RouteSpecification(path))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// A list of this specification's [RouteSegment]s.
|
||||
late List<RouteSegment> segments;
|
||||
|
||||
/// A list of all variables in this route.
|
||||
late List<String> variableNames;
|
||||
|
||||
/// A reference back to the [Controller] to be used when this specification is matched.
|
||||
Controller? controller;
|
||||
|
||||
@override
|
||||
String toString() => segments.join("/");
|
||||
}
|
||||
|
||||
List<String> _pathsFromRoutePattern(String inputPattern) {
|
||||
var routePattern = inputPattern;
|
||||
var endingOptionalCloseCount = 0;
|
||||
while (routePattern.endsWith("]")) {
|
||||
routePattern = routePattern.substring(0, routePattern.length - 1);
|
||||
endingOptionalCloseCount++;
|
||||
}
|
||||
|
||||
final chars = routePattern.codeUnits;
|
||||
final patterns = <String>[];
|
||||
final buffer = StringBuffer();
|
||||
final openOptional = '['.codeUnitAt(0);
|
||||
final openExpression = '('.codeUnitAt(0);
|
||||
final closeExpression = ')'.codeUnitAt(0);
|
||||
|
||||
bool insideExpression = false;
|
||||
for (var i = 0; i < chars.length; i++) {
|
||||
final code = chars[i];
|
||||
|
||||
if (code == openExpression) {
|
||||
if (insideExpression) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$routePattern' cannot use expression that contains '(' or ')'",
|
||||
);
|
||||
} else {
|
||||
buffer.writeCharCode(code);
|
||||
insideExpression = true;
|
||||
}
|
||||
} else if (code == closeExpression) {
|
||||
if (insideExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
insideExpression = false;
|
||||
} else {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$routePattern' cannot use expression that contains '(' or ')'",
|
||||
);
|
||||
}
|
||||
} else if (code == openOptional) {
|
||||
if (insideExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
} else {
|
||||
patterns.add(buffer.toString());
|
||||
}
|
||||
} else {
|
||||
buffer.writeCharCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (insideExpression) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$routePattern' has unterminated regular expression.",
|
||||
);
|
||||
}
|
||||
|
||||
if (endingOptionalCloseCount != patterns.length) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$routePattern' does not close all optionals.",
|
||||
);
|
||||
}
|
||||
|
||||
// Add the final pattern - if no optionals, this is the only pattern.
|
||||
patterns.add(buffer.toString());
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
List<RouteSegment> _splitPathSegments(String inputPath) {
|
||||
var path = inputPath;
|
||||
// Once we've gotten into this method, the path has been validated for optionals and regex and optionals have been removed.
|
||||
|
||||
// Trim leading and trailing
|
||||
while (path.startsWith("/")) {
|
||||
path = path.substring(1, path.length);
|
||||
}
|
||||
while (path.endsWith("/")) {
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
|
||||
final segments = <String>[];
|
||||
final chars = path.codeUnits;
|
||||
var buffer = StringBuffer();
|
||||
|
||||
final openExpression = '('.codeUnitAt(0);
|
||||
final closeExpression = ')'.codeUnitAt(0);
|
||||
final pathDelimiter = '/'.codeUnitAt(0);
|
||||
bool insideExpression = false;
|
||||
|
||||
for (var i = 0; i < path.length; i++) {
|
||||
final code = chars[i];
|
||||
|
||||
if (code == openExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
insideExpression = true;
|
||||
} else if (code == closeExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
insideExpression = false;
|
||||
} else if (code == pathDelimiter) {
|
||||
if (insideExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
} else {
|
||||
segments.add(buffer.toString());
|
||||
buffer = StringBuffer();
|
||||
}
|
||||
} else {
|
||||
buffer.writeCharCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.any((seg) => seg == "")) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$path' contains an empty path segment.",
|
||||
);
|
||||
}
|
||||
|
||||
// Add final
|
||||
segments.add(buffer.toString());
|
||||
|
||||
return segments.map((seg) => RouteSegment(seg)).toList();
|
||||
}
|
242
packages/http/lib/src/router.dart
Normal file
242
packages/http/lib/src/router.dart
Normal file
|
@ -0,0 +1,242 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// Determines which [Controller] should receive a [Request] based on its path.
|
||||
///
|
||||
/// A router is a [Controller] that evaluates the path of a [Request] and determines which controller should be the next to receive it.
|
||||
/// Valid paths for a [Router] are called *routes* and are added to a [Router] via [route].
|
||||
///
|
||||
/// Each [route] creates a new [Controller] that will receive all requests whose path match the route pattern.
|
||||
/// If a request path does not match one of the registered routes, [Router] responds with 404 Not Found and does not pass
|
||||
/// the request to another controller.
|
||||
///
|
||||
/// Unlike most [Controller]s, a [Router] may have multiple controllers it sends requests to. In most applications,
|
||||
/// a [Router] is the [ApplicationChannel.entryPoint].
|
||||
class Router extends Controller {
|
||||
/// Creates a new [Router].
|
||||
Router({String? basePath, Future Function(Request)? notFoundHandler})
|
||||
: _unmatchedController = notFoundHandler,
|
||||
_basePathSegments =
|
||||
basePath?.split("/").where((str) => str.isNotEmpty).toList() ?? [] {
|
||||
policy?.allowCredentials = false;
|
||||
}
|
||||
|
||||
final _RootNode _root = _RootNode();
|
||||
final List<_RouteController> _routeControllers = [];
|
||||
final List<String> _basePathSegments;
|
||||
final Function(Request)? _unmatchedController;
|
||||
|
||||
/// A prefix for all routes on this instance.
|
||||
///
|
||||
/// If this value is non-null, each [route] is prefixed by this value.
|
||||
///
|
||||
/// For example, if a route is "/users" and the value of this property is "/api",
|
||||
/// a request's path must be "/api/users" to match the route.
|
||||
///
|
||||
/// Trailing and leading slashes have no impact on this value.
|
||||
String get basePath => "/${_basePathSegments.join("/")}";
|
||||
|
||||
/// Adds a route that [Controller]s can be linked to.
|
||||
///
|
||||
/// Routers allow for multiple linked controllers. A request that matches [pattern]
|
||||
/// will be sent to the controller linked to this method's return value.
|
||||
///
|
||||
/// The [pattern] must follow the rules of route patterns (see also http://conduit.io/docs/http/routing/).
|
||||
///
|
||||
/// A pattern consists of one or more path segments, e.g. "/path" or "/path/to".
|
||||
///
|
||||
/// A path segment can be:
|
||||
///
|
||||
/// - A literal string (e.g. `users`)
|
||||
/// - A path variable: a literal string prefixed with `:` (e.g. `:id`)
|
||||
/// - A wildcard: the character `*`
|
||||
///
|
||||
/// A path variable may contain a regular expression by placing the expression in parentheses immediately after the variable name. (e.g. `:id(/d+)`).
|
||||
///
|
||||
/// A path segment is required by default. Path segments may be marked as optional
|
||||
/// by wrapping them in square brackets `[]`.
|
||||
///
|
||||
/// Here are some example routes:
|
||||
///
|
||||
/// /users
|
||||
/// /users/:id
|
||||
/// /users/[:id]
|
||||
/// /users/:id/friends/[:friendID]
|
||||
/// /locations/:name([^0-9])
|
||||
/// /files/*
|
||||
///
|
||||
Linkable route(String pattern) {
|
||||
final routeController = _RouteController(
|
||||
RouteSpecification.specificationsForRoutePattern(pattern),
|
||||
);
|
||||
_routeControllers.add(routeController);
|
||||
return routeController;
|
||||
}
|
||||
|
||||
@override
|
||||
void didAddToChannel() {
|
||||
_root.node =
|
||||
RouteNode(_routeControllers.expand((rh) => rh.specifications).toList());
|
||||
|
||||
for (final c in _routeControllers) {
|
||||
c.didAddToChannel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routers override this method to throw an exception. Use [route] instead.
|
||||
@override
|
||||
Linkable link(Controller Function() generatorFunction) {
|
||||
throw ArgumentError(
|
||||
"Invalid link. 'Router' cannot directly link to controllers. Use 'route'.",
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Linkable? linkFunction(
|
||||
FutureOr<RequestOrResponse?> Function(Request request) handle,
|
||||
) {
|
||||
throw ArgumentError(
|
||||
"Invalid link. 'Router' cannot directly link to functions. Use 'route'.",
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future receive(Request req) async {
|
||||
Controller next;
|
||||
try {
|
||||
var requestURISegmentIterator = req.raw.uri.pathSegments.iterator;
|
||||
|
||||
if (req.raw.uri.pathSegments.isEmpty) {
|
||||
requestURISegmentIterator = [""].iterator;
|
||||
}
|
||||
|
||||
for (var i = 0; i < _basePathSegments.length; i++) {
|
||||
requestURISegmentIterator.moveNext();
|
||||
if (_basePathSegments[i] != requestURISegmentIterator.current) {
|
||||
await _handleUnhandledRequest(req);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final node =
|
||||
_root.node!.nodeForPathSegments(requestURISegmentIterator, req.path);
|
||||
if (node?.specification == null) {
|
||||
await _handleUnhandledRequest(req);
|
||||
return null;
|
||||
}
|
||||
req.path.setSpecification(
|
||||
node!.specification!,
|
||||
segmentOffset: _basePathSegments.length,
|
||||
);
|
||||
next = node.controller!;
|
||||
} catch (any, stack) {
|
||||
return handleError(req, any, stack);
|
||||
}
|
||||
|
||||
// This line is intentionally outside of the try block
|
||||
// so that this object doesn't handle exceptions for 'next'.
|
||||
return next.receive(req);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> handle(Request request) {
|
||||
throw StateError("Router invoked handle. This is a bug.");
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIPath> documentPaths(APIDocumentContext context) {
|
||||
return _routeControllers.fold(<String, APIPath>{}, (prev, elem) {
|
||||
prev.addAll(elem.documentPaths(context));
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
for (final controller in _routeControllers) {
|
||||
controller.documentComponents(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _root.node.toString();
|
||||
}
|
||||
|
||||
Future _handleUnhandledRequest(Request req) async {
|
||||
if (_unmatchedController != null) {
|
||||
return _unmatchedController(req);
|
||||
}
|
||||
final response = Response.notFound();
|
||||
if (req.acceptsContentType(ContentType.html)) {
|
||||
response
|
||||
..body = "<html><h3>404 Not Found</h3></html>"
|
||||
..contentType = ContentType.html;
|
||||
}
|
||||
|
||||
applyCORSHeadersIfNecessary(req, response);
|
||||
await req.respond(response);
|
||||
logger.info(req.toDebugString());
|
||||
}
|
||||
}
|
||||
|
||||
class _RootNode {
|
||||
RouteNode? node;
|
||||
}
|
||||
|
||||
class _RouteController extends Controller {
|
||||
_RouteController(this.specifications) {
|
||||
for (final p in specifications) {
|
||||
p.controller = this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Route specifications for this controller.
|
||||
final List<RouteSpecification> specifications;
|
||||
|
||||
@override
|
||||
Map<String, APIPath> documentPaths(APIDocumentContext components) {
|
||||
return specifications.fold(<String, APIPath>{}, (pathMap, spec) {
|
||||
final elements = spec.segments.map((rs) {
|
||||
if (rs.isLiteralMatcher) {
|
||||
return rs.literal;
|
||||
} else if (rs.isVariable) {
|
||||
return "{${rs.variableName}}";
|
||||
} else if (rs.isRemainingMatcher) {
|
||||
return "{path}";
|
||||
}
|
||||
throw StateError("unknown specification");
|
||||
}).join("/");
|
||||
final pathKey = "/$elements";
|
||||
|
||||
final path = APIPath()
|
||||
..parameters = spec.variableNames
|
||||
.map((pathVar) => APIParameter.path(pathVar))
|
||||
.toList();
|
||||
|
||||
if (spec.segments.any((seg) => seg.isRemainingMatcher)) {
|
||||
path.parameters.add(
|
||||
APIParameter.path("path")
|
||||
..description =
|
||||
"This path variable may contain slashes '/' and may be empty.",
|
||||
);
|
||||
}
|
||||
|
||||
path.operations =
|
||||
spec.controller!.documentOperations(components, pathKey, path);
|
||||
|
||||
pathMap[pathKey] = path;
|
||||
|
||||
return pathMap;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> handle(Request request) {
|
||||
return request;
|
||||
}
|
||||
}
|
119
packages/http/lib/src/serializable.dart
Normal file
119
packages/http/lib/src/serializable.dart
Normal file
|
@ -0,0 +1,119 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Interface for serializable instances to be decoded from an HTTP request body and encoded to an HTTP response body.
|
||||
///
|
||||
/// Implementers of this interface may be a [Response.body] and bound with an [Bind.body] in [ResourceController].
|
||||
abstract class Serializable {
|
||||
/// Returns an [APISchemaObject] describing this object's type.
|
||||
///
|
||||
/// The returned [APISchemaObject] will be of type [APIType.object]. By default, each instance variable
|
||||
/// of the receiver's type will be a property of the return value.
|
||||
APISchemaObject documentSchema(APIDocumentContext context) {
|
||||
return (RuntimeContext.current[runtimeType] as SerializableRuntime)
|
||||
.documentSchema(context);
|
||||
}
|
||||
|
||||
/// Reads values from [object].
|
||||
///
|
||||
/// Use [read] instead of this method. [read] applies filters
|
||||
/// to [object] before calling this method.
|
||||
///
|
||||
/// This method is used by implementors to assign and use values from [object] for its own
|
||||
/// purposes. [SerializableException]s should be thrown when [object] violates a constraint
|
||||
/// of the receiver.
|
||||
void readFromMap(Map<String, dynamic> object);
|
||||
|
||||
/// Reads values from [object], after applying filters.
|
||||
///
|
||||
/// The key name must exactly match the name of the property as defined in the receiver's type.
|
||||
/// If [object] contains a key that is unknown to the receiver, an exception is thrown (status code: 400).
|
||||
///
|
||||
/// [accept], [ignore], [reject] and [require] are filters on [object]'s keys with the following behaviors:
|
||||
///
|
||||
/// If [accept] is set, all values for the keys that are not given are ignored and discarded.
|
||||
/// If [ignore] is set, all values for the given keys are ignored and discarded.
|
||||
/// If [reject] is set, if [object] contains any of these keys, a status code 400 exception is thrown.
|
||||
/// If [require] is set, all keys must be present in [object].
|
||||
///
|
||||
/// Usage:
|
||||
/// var values = json.decode(await request.body.decode());
|
||||
/// var user = User()
|
||||
/// ..read(values, ignore: ["id"]);
|
||||
void read(
|
||||
Map<String, dynamic> object, {
|
||||
Iterable<String>? accept,
|
||||
Iterable<String>? ignore,
|
||||
Iterable<String>? reject,
|
||||
Iterable<String>? require,
|
||||
}) {
|
||||
if (accept == null && ignore == null && reject == null && require == null) {
|
||||
readFromMap(object);
|
||||
return;
|
||||
}
|
||||
|
||||
final copy = Map<String, dynamic>.from(object);
|
||||
final stillRequired = require?.toList();
|
||||
for (final key in object.keys) {
|
||||
if (reject?.contains(key) ?? false) {
|
||||
throw SerializableException(["invalid input key '$key'"]);
|
||||
}
|
||||
if ((ignore?.contains(key) ?? false) ||
|
||||
!(accept?.contains(key) ?? true)) {
|
||||
copy.remove(key);
|
||||
}
|
||||
stillRequired?.remove(key);
|
||||
}
|
||||
|
||||
if (stillRequired?.isNotEmpty ?? false) {
|
||||
throw SerializableException(
|
||||
["missing required input key(s): '${stillRequired!.join(", ")}'"],
|
||||
);
|
||||
}
|
||||
|
||||
readFromMap(copy);
|
||||
}
|
||||
|
||||
/// Returns a serializable version of an object.
|
||||
///
|
||||
/// This method returns a [Map<String, dynamic>] where each key is the name of a property in the implementing type.
|
||||
/// If a [Response.body]'s type implements this interface, this method is invoked prior to any content-type encoding
|
||||
/// performed by the [Response]. A [Response.body] may also be a [List<Serializable>], for which this method is invoked on
|
||||
/// each element in the list.
|
||||
Map<String, dynamic> asMap();
|
||||
|
||||
/// Whether a subclass will automatically be registered as a schema component automatically.
|
||||
///
|
||||
/// Defaults to true. When an instance of this subclass is used in a [ResourceController],
|
||||
/// it will automatically be registered as a schema component. Its properties will be reflected
|
||||
/// on to create the [APISchemaObject]. If false, you must register a schema for the subclass manually.
|
||||
///
|
||||
/// Overriding static methods is not enforced by the Dart compiler - check for typos.
|
||||
static bool get shouldAutomaticallyDocument => true;
|
||||
}
|
||||
|
||||
class SerializableException implements HandlerException {
|
||||
SerializableException(this.reasons);
|
||||
|
||||
final List<String> reasons;
|
||||
|
||||
@override
|
||||
Response get response {
|
||||
return Response.badRequest(
|
||||
body: {"error": "entity validation failed", "reasons": reasons},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final errorString = response.body["error"] as String?;
|
||||
final reasons = (response.body["reasons"] as List).join(", ");
|
||||
return "$errorString $reasons";
|
||||
}
|
||||
}
|
||||
|
||||
abstract class SerializableRuntime {
|
||||
APISchemaObject documentSchema(APIDocumentContext context);
|
||||
}
|
|
@ -10,7 +10,14 @@ environment:
|
|||
|
||||
# Add regular dependencies here.
|
||||
dependencies:
|
||||
#path: ^1.9.0
|
||||
protevus_runtime: ^0.0.1
|
||||
protevus_openapi: ^0.0.1
|
||||
protevus_auth: ^0.0.1
|
||||
protevus_database: ^0.0.1
|
||||
collection: ^1.18.0
|
||||
logging: ^1.2.0
|
||||
meta: ^1.12.0
|
||||
path: ^1.9.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^3.0.0
|
||||
|
|
Loading…
Reference in a new issue