add(conduit): refactoring conduit core

This commit is contained in:
Patrick Stewart 2024-09-03 13:17:30 -07:00
parent fdbbe2eab5
commit 3e91c05603
24 changed files with 4519 additions and 1 deletions

View 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';

View 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();
}
}

View 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(", ");
}
}

View 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;
}

View 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);
}
}

View 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;
}

View 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;
}

View 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();
}
}

View 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();
}
}
}

View 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);
}
}

View 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;
}

View 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;
}

View 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("/")}";
}

View 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;
}
}

View 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();
}

View 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;
}

View 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;
}

View 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;
}
}

View 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();
}
}

View 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();
}

View 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;
}
}

View 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);
}

View file

@ -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