diff --git a/packages/http/lib/http.dart b/packages/http/lib/http.dart new file mode 100644 index 0000000..397ba63 --- /dev/null +++ b/packages/http/lib/http.dart @@ -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'; diff --git a/packages/http/lib/src/.gitkeep b/packages/http/lib/src/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/packages/http/lib/src/body_decoder.dart b/packages/http/lib/src/body_decoder.dart new file mode 100644 index 0000000..ff715e5 --- /dev/null +++ b/packages/http/lib/src/body_decoder.dart @@ -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> bodyByteStream) + : _originalByteStream = bodyByteStream; + + /// The stream of bytes to decode. + /// + /// This stream is consumed during decoding. + Stream> 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? get originalBytes { + if (retainOriginalBytes == false) { + throw StateError( + "'originalBytes' were not retained. Set 'retainOriginalBytes' to true prior to decoding.", + ); + } + return _bytes; + } + + final Stream> _originalByteStream; + dynamic _decodedData; + List? _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 decode() async { + if (hasBeenDecoded) { + return _cast(_decodedData); + } + + final codec = + CodecRegistry.defaultInstance.codecForContentType(contentType); + final originalBytes = await _readBytes(bytes); + + if (retainOriginalBytes) { + _bytes = originalBytes; + } + + if (codec == null) { + _decodedData = originalBytes; + return _cast(_decodedData); + } + + try { + _decodedData = codec.decoder.convert(originalBytes); + } on Response { + rethrow; + } catch (_) { + throw Response.badRequest( + body: {"error": "request entity could not be decoded"}, + ); + } + + return _cast(_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() { + if (!hasBeenDecoded) { + throw StateError("Attempted to access request body without decoding it."); + } + + return _cast(_decodedData); + } + + T _cast(dynamic body) { + try { + return RuntimeContext.current.coerce(body); + } on TypeCoercionException { + throw Response.badRequest( + body: {"error": "request entity was unexpected type"}, + ); + } + } + + Future> _readBytes(Stream> stream) async { + return (await stream.toList()).expand((e) => e).toList(); + } +} diff --git a/packages/http/lib/src/cache_policy.dart b/packages/http/lib/src/cache_policy.dart new file mode 100644 index 0000000..ff883ef --- /dev/null +++ b/packages/http/lib/src/cache_policy.dart @@ -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(", "); + } +} diff --git a/packages/http/lib/src/controller.dart b/packages/http/lib/src/controller.dart new file mode 100644 index 0000000..a15a654 --- /dev/null +++ b/packages/http/lib/src/controller.dart @@ -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 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 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 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 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 documentPaths(APIDocumentContext context) => + nextController?.documentPaths(context) ?? {}; + + @override + Map 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 extends Controller { + _ControllerRecycler(this.generator, Recyclable instance) { + recycleState = instance.recycledState; + nextInstanceToReceive = instance; + } + + Controller Function() generator; + CORSPolicy? policyOverride; + T? recycleState; + + Recyclable? _nextInstanceToReceive; + + Recyclable? get nextInstanceToReceive => _nextInstanceToReceive; + + set nextInstanceToReceive(Recyclable? 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 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; + return next!.receive(req); + } + + @override + FutureOr 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 documentPaths(APIDocumentContext components) => + nextInstanceToReceive?.documentPaths(components) ?? {}; + + @override + Map documentOperations( + APIDocumentContext components, + String route, + APIPath path, + ) => + nextInstanceToReceive?.documentOperations(components, route, path) ?? {}; +} + +@PreventCompilation() +class _FunctionController extends Controller { + _FunctionController(this._handler); + + final FutureOr Function(Request) _handler; + + @override + FutureOr handle(Request request) { + return _handler(request); + } + + @override + Map 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; +} diff --git a/packages/http/lib/src/cors_policy.dart b/packages/http/lib/src/cors_policy.dart new file mode 100644 index 0000000..07e811e --- /dev/null +++ b/packages/http/lib/src/cors_policy.dart @@ -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 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 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 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 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 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 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 headersForRequest(Request request) { + final origin = request.raw.headers.value("origin"); + + final headers = {}; + 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); + } +} diff --git a/packages/http/lib/src/file_controller.dart b/packages/http/lib/src/file_controller.dart new file mode 100644 index 0000000..7b35826 --- /dev/null +++ b/packages/http/lib/src/file_controller.dart @@ -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 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 _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 _extensionMap = Map.from(_defaultExtensionMap); + final List<_PolicyPair?> _policyPairs = []; + final Uri _servingDirectory; + final FutureOr 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 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 = "

404 Not Found

" + ..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 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; +} diff --git a/packages/http/lib/src/handler_exception.dart b/packages/http/lib/src/handler_exception.dart new file mode 100644 index 0000000..492d01e --- /dev/null +++ b/packages/http/lib/src/handler_exception.dart @@ -0,0 +1,9 @@ +import 'package:protevus_http/http.dart'; + +class HandlerException implements Exception { + HandlerException(this._response); + + Response get response => _response; + + final Response _response; +} diff --git a/packages/http/lib/src/http_codec_repository.dart b/packages/http/lib/src/http_codec_repository.dart new file mode 100644 index 0000000..f19f07f --- /dev/null +++ b/packages/http/lib/src/http_codec_repository.dart @@ -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 _primaryTypeCodecs = {}; + final Map> _fullySpecificedCodecs = {}; + final Map _primaryTypeCompressionMap = {}; + final Map> _fullySpecifiedCompressionMap = {}; + final Map> _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] (or used chunked conversion to create a `Stream>`). + /// + /// [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] + /// + /// 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>? codecForContentType(ContentType? contentType) { + if (contentType == null) { + return null; + } + + Codec? contentCodec; + Codec>? 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>) { + throw StateError("Invalid codec selected. Does not emit 'List'."); + } + return contentCodec; + } + + if (charsetCodec != null) { + return charsetCodec; + } + + return null; + } + + Codec> _codecForCharset(String? charset) { + final encoding = Encoding.getByName(charset); + if (encoding == null) { + throw Response(415, null, {"error": "invalid charset '$charset'"}); + } + + return encoding; + } + + Codec>? _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?, dynamic> { + const _FormCodec(); + + @override + Converter, String> get encoder => const _FormEncoder(); + + @override + Converter> get decoder => const _FormDecoder(); +} + +class _FormEncoder extends Converter, String> { + const _FormEncoder(); + + @override + String convert(Map 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) { + 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'", + ); + } +} + +class _FormDecoder extends Converter> { + // This class may take input as either String or List. If charset is not defined in request, + // then data is List (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 convert(String data) { + return Uri(query: data).queryParametersAll; + } + + @override + _FormSink startChunkedConversion(Sink> outSink) { + return _FormSink(outSink); + } +} + +class _FormSink implements ChunkedConversionSink { + _FormSink(this._outSink); + + final _FormDecoder decoder = const _FormDecoder(); + final Sink> _outSink; + final StringBuffer _buffer = StringBuffer(); + + @override + void add(String data) { + _buffer.write(data); + } + + @override + void close() { + _outSink.add(decoder.convert(_buffer.toString())); + _outSink.close(); + } +} diff --git a/packages/http/lib/src/managed_object_controller.dart b/packages/http/lib/src/managed_object_controller.dart new file mode 100644 index 0000000..f24ade8 --- /dev/null +++ b/packages/http/lib/src/managed_object_controller.dart @@ -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 can be any string: +/// +/// router.route("//[: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 //:id -> Fetch Object by ID +/// - PUT //:id -> Update Object by ID, HTTP Request Body contains update values. +/// - DELETE //:id -> Delete Object by ID +/// - POST / -> Create new Object, HTTP Request Body contains update values. +/// - GET / -> 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 + extends ResourceController { + /// Creates an instance of a [ManagedObjectController]. + ManagedObjectController(ManagedContext context) : super() { + _query = Query(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? _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?> willFindObjectWithQuery( + Query? 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 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 didNotFindObject() { + return Response.notFound(); + } + + @Operation.get("id") + Future 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?> willInsertObjectWithQuery( + Query? 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 didInsertObject(InstanceType object) { + return Response.ok(object); + } + + @Operation.post() + Future 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?> willDeleteObjectWithQuery( + Query? 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 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 didNotFindObjectToDeleteWithID(dynamic id) { + return Response.notFound(); + } + + @Operation.delete("id") + Future 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?> willUpdateObjectWithQuery( + Query? 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 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 didNotFindObjectToUpdateWithID(dynamic id) { + return Response.notFound(); + } + + @Operation.put("id") + Future 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?> willFindObjectsWithQuery( + Query? 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 didFindObjects(List objects) { + return Response.ok(objects); + } + + @Operation.get() + Future 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? 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 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 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(); + } + } +} diff --git a/packages/http/lib/src/query_controller.dart b/packages/http/lib/src/query_controller.dart new file mode 100644 index 0000000..4bbc9d8 --- /dev/null +++ b/packages/http/lib/src/query_controller.dart @@ -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 + extends ResourceController { + /// Create an instance of [QueryController]. + QueryController(ManagedContext context) : super() { + query = Query(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? query; + + @override + FutureOr 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); + } +} diff --git a/packages/http/lib/src/request.dart b/packages/http/lib/src/request.dart new file mode 100644 index 0000000..8957af3 --- /dev/null +++ b/packages/http/lib/src/request.dart @@ -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? _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 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? _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 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 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 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) { + 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? _responseBodyBytes( + Response resp, + _Reference compressionType, + ) { + if (resp.body == null) { + return null; + } + + Codec>? 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) { + 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; + 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> _responseBodyStream( + Response resp, + _Reference compressionType, + ) { + Codec>? 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>) { + 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>; + 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 { + _Reference(this.value); + + T? value; +} diff --git a/packages/http/lib/src/request_body.dart b/packages/http/lib/src/request_body.dart new file mode 100644 index 0000000..2fec60e --- /dev/null +++ b/packages/http/lib/src/request_body.dart @@ -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> 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>(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> _originalByteStream; + StreamController>? _bufferingController; + int _bytesRead = 0; +} diff --git a/packages/http/lib/src/request_path.dart b/packages/http/lib/src/request_path.dart new file mode 100644 index 0000000..893c654 --- /dev/null +++ b/packages/http/lib/src/request_path.dart @@ -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 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 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 orderedVariableNames = []; + + /// The path of the requested URI. + /// + /// Always contains a leading '/', but never a trailing '/'. + String get string => "/${segments.join("/")}"; +} diff --git a/packages/http/lib/src/resource_controller.dart b/packages/http/lib/src/resource_controller.dart new file mode 100755 index 0000000..0dc071c --- /dev/null +++ b/packages/http/lib/src/resource_controller.dart @@ -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 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 getEmployees() async { +/// return Response.ok(employees); +/// } +/// +/// // This method gets invoked when the path is '/employees/id' +/// @Operation.get('id') +/// Future 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 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 { + 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 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 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 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 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? 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 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 documentOperationTags( + APIDocumentContext context, + Operation? operation, + ) { + final tag = "$runtimeType".replaceAll("Controller", ""); + return [tag]; + } + + @override + Map 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 _allowedMethodsForPathVariables( + Iterable pathVariables, + ) { + return _runtime!.operations + .where((op) => op.isSuitableForRequest(null, pathVariables.toList())) + .map((op) => op.httpMethod) + .toList(); + } + + Future _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 = []; + 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>(); + + args.namedArguments = Map.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>(); + + args.instanceVariables = Map.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; + } +} diff --git a/packages/http/lib/src/resource_controller_bindings.dart b/packages/http/lib/src/resource_controller_bindings.dart new file mode 100644 index 0000000..70cb393 --- /dev/null +++ b/packages/http/lib/src/resource_controller_bindings.dart @@ -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 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 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 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 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]. If the property or argument is a [List], 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 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 getUsers() async => Response.ok(getAllUsers()); + /// @Operation.get('id') + /// Future 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? accept; + final List? ignore; + final List? reject; + final List? 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 getUser(@Bind.path("id") int id) async +/// => return Response.ok(await getUserByID(id)); +/// +/// @Operation.get() +/// Future getAllUsers() async +/// => return Response.ok(await getUsers()); +/// } +const RequiredBinding requiredBinding = RequiredBinding(); + +/// See [requiredBinding]. +class RequiredBinding { + const RequiredBinding(); +} diff --git a/packages/http/lib/src/resource_controller_interfaces.dart b/packages/http/lib/src/resource_controller_interfaces.dart new file mode 100755 index 0000000..3b9759c --- /dev/null +++ b/packages/http/lib/src/resource_controller_interfaces.dart @@ -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? ivarParameters; + late List operations; + + ResourceControllerDocumenter? documenter; + + ResourceControllerOperation? getOperationRuntime( + String method, + List 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 documentOperationParameters( + ResourceController rc, + APIDocumentContext context, + Operation? operation, + ); + + APIRequestBody? documentOperationRequestBody( + ResourceController rc, + APIDocumentContext context, + Operation? operation, + ); + + Map 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? scopes; + final List pathVariables; + final String httpMethod; + final String dartMethodName; + + final List positionalParameters; + final List namedParameters; + + final Future 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 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({ + required String symbolName, + required String? name, + required BindingType location, + required bool isRequired, + required dynamic Function(dynamic input) decoder, + required dynamic defaultValue, + required List? acceptFilter, + required List? ignoreFilter, + required List? requireFilter, + required List? 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? acceptFilter; + final List? ignoreFilter; + final List? requireFilter; + final List? 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>>()[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 instanceVariables; + late Map namedArguments; + late List positionalArguments; +} diff --git a/packages/http/lib/src/resource_controller_scope.dart b/packages/http/lib/src/resource_controller_scope.dart new file mode 100644 index 0000000..4100f64 --- /dev/null +++ b/packages/http/lib/src/resource_controller_scope.dart @@ -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 getNote(@Bind.path('id') int id) async { +/// ... +/// } +/// +/// @Scope(['notes']); +/// @Operation.post() +/// Future 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 scopes; +} diff --git a/packages/http/lib/src/response.dart b/packages/http/lib/src/response.dart new file mode 100644 index 0000000..56028de --- /dev/null +++ b/packages/http/lib/src/response.dart @@ -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? headers, dynamic body) { + this.body = body; + this.headers = LinkedHashMap( + 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? 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? headers, + }) : this( + HttpStatus.created, + _headersWith(headers, {HttpHeaders.locationHeader: location}), + body, + ); + + /// Represents a 202 response. + Response.accepted({Map? headers}) + : this(HttpStatus.accepted, headers, null); + + /// Represents a 204 response. + Response.noContent({Map? 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? headers, dynamic body}) + : this(HttpStatus.badRequest, headers, body); + + /// Represents a 401 response. + Response.unauthorized({Map? headers, dynamic body}) + : this(HttpStatus.unauthorized, headers, body); + + /// Represents a 403 response. + Response.forbidden({Map? headers, dynamic body}) + : this(HttpStatus.forbidden, headers, body); + + /// Represents a 404 response. + Response.notFound({Map? headers, dynamic body}) + : this(HttpStatus.notFound, headers, body); + + /// Represents a 409 response. + Response.conflict({Map? headers, dynamic body}) + : this(HttpStatus.conflict, headers, body); + + /// Represents a 410 response. + Response.gone({Map? headers, dynamic body}) + : this(HttpStatus.gone, headers, body); + + /// Represents a 500 response. + Response.serverError({Map? 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) { + 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 get headers => _headers; + set headers(Map h) { + _headers.clear(); + _headers.addAll(h); + } + + final Map _headers = LinkedHashMap( + 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 _headersWith( + Map? inputHeaders, + Map otherHeaders, + ) { + final m = LinkedHashMap( + equals: (a, b) => a.toLowerCase() == b.toLowerCase(), + hashCode: (key) => key.toLowerCase().hashCode); + if (inputHeaders != null) { + m.addAll(inputHeaders); + } + m.addAll(otherHeaders); + return m; + } +} diff --git a/packages/http/lib/src/route_node.dart b/packages/http/lib/src/route_node.dart new file mode 100644 index 0000000..b79f431 --- /dev/null +++ b/packages/http/lib/src/route_node.dart @@ -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 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.from( + specs.where((spec) => depth != spec?.segments.length), + ); + + final Set 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 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 patternedChildren = []; + + // Includes children that are literal path segments that can be matched with simple string equality + Map equalityChildren = {}; + + // Valid if has child that is a take all (*) segment. + RouteNode? takeAllChild; + + RouteNode? nodeForPathSegments( + Iterator 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(); + } +} diff --git a/packages/http/lib/src/route_specification.dart b/packages/http/lib/src/route_specification.dart new file mode 100644 index 0000000..c7ee95a --- /dev/null +++ b/packages/http/lib/src/route_specification.dart @@ -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 specificationsForRoutePattern( + String routePattern, + ) { + return _pathsFromRoutePattern(routePattern) + .map((path) => RouteSpecification(path)) + .toList(); + } + + /// A list of this specification's [RouteSegment]s. + late List segments; + + /// A list of all variables in this route. + late List variableNames; + + /// A reference back to the [Controller] to be used when this specification is matched. + Controller? controller; + + @override + String toString() => segments.join("/"); +} + +List _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 = []; + 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 _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 = []; + 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(); +} diff --git a/packages/http/lib/src/router.dart b/packages/http/lib/src/router.dart new file mode 100644 index 0000000..66420d1 --- /dev/null +++ b/packages/http/lib/src/router.dart @@ -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 _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 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 handle(Request request) { + throw StateError("Router invoked handle. This is a bug."); + } + + @override + Map documentPaths(APIDocumentContext context) { + return _routeControllers.fold({}, (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 = "

404 Not Found

" + ..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 specifications; + + @override + Map documentPaths(APIDocumentContext components) { + return specifications.fold({}, (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 handle(Request request) { + return request; + } +} diff --git a/packages/http/lib/src/serializable.dart b/packages/http/lib/src/serializable.dart new file mode 100644 index 0000000..7a7c32c --- /dev/null +++ b/packages/http/lib/src/serializable.dart @@ -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 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 object, { + Iterable? accept, + Iterable? ignore, + Iterable? reject, + Iterable? require, + }) { + if (accept == null && ignore == null && reject == null && require == null) { + readFromMap(object); + return; + } + + final copy = Map.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] 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], for which this method is invoked on + /// each element in the list. + Map 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 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); +} diff --git a/packages/http/pubspec.yaml b/packages/http/pubspec.yaml index 0201ec3..8c08c64 100644 --- a/packages/http/pubspec.yaml +++ b/packages/http/pubspec.yaml @@ -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