Compare commits
10 commits
903fa6c356
...
8000137ded
Author | SHA1 | Date | |
---|---|---|---|
|
8000137ded | ||
|
7a095316dc | ||
|
ceb360eb9c | ||
|
57812c8638 | ||
|
3e91c05603 | ||
|
fdbbe2eab5 | ||
|
1e1d0ad6a3 | ||
|
50322e71b2 | ||
|
bbc1e48740 | ||
|
fe9ccd7d7c |
193 changed files with 282104 additions and 3 deletions
7
packages/app/.gitignore
vendored
Normal file
7
packages/app/.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
# https://dart.dev/guides/libraries/private-files
|
||||
# Created by `dart pub`
|
||||
.dart_tool/
|
||||
|
||||
# Avoid committing pubspec.lock for library packages; see
|
||||
# https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
pubspec.lock
|
3
packages/app/CHANGELOG.md
Normal file
3
packages/app/CHANGELOG.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
## 1.0.0
|
||||
|
||||
- Initial version.
|
10
packages/app/LICENSE.md
Normal file
10
packages/app/LICENSE.md
Normal file
|
@ -0,0 +1,10 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
The Laravel Framework is Copyright (c) Taylor Otwell
|
||||
The Fabric Framework is Copyright (c) Vieo, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
1
packages/app/README.md
Normal file
1
packages/app/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
<p align="center"><a href="https://protevus.com" target="_blank"><img src="https://git.protevus.com/protevus/branding/raw/branch/main/protevus-logo-bg.png"></a></p>
|
30
packages/app/analysis_options.yaml
Normal file
30
packages/app/analysis_options.yaml
Normal file
|
@ -0,0 +1,30 @@
|
|||
# This file configures the static analysis results for your project (errors,
|
||||
# warnings, and lints).
|
||||
#
|
||||
# This enables the 'recommended' set of lints from `package:lints`.
|
||||
# This set helps identify many issues that may lead to problems when running
|
||||
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
|
||||
# style and format.
|
||||
#
|
||||
# If you want a smaller set of lints you can change this to specify
|
||||
# 'package:lints/core.yaml'. These are just the most critical lints
|
||||
# (the recommended set includes the core lints).
|
||||
# The core lints are also what is used by pub.dev for scoring packages.
|
||||
|
||||
include: package:lints/recommended.yaml
|
||||
|
||||
# Uncomment the following section to specify additional rules.
|
||||
|
||||
# linter:
|
||||
# rules:
|
||||
# - camel_case_types
|
||||
|
||||
# analyzer:
|
||||
# exclude:
|
||||
# - path/to/excluded/files/**
|
||||
|
||||
# For more information about the core and recommended set of lints, see
|
||||
# https://dart.dev/go/core-lints
|
||||
|
||||
# For additional information about configuring this file, see
|
||||
# https://dart.dev/guides/language/analysis-options
|
18
packages/app/lib/application.dart
Normal file
18
packages/app/lib/application.dart
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
library;
|
||||
|
||||
export 'src/application.dart';
|
||||
export 'src/application_server.dart';
|
||||
export 'src/channel.dart';
|
||||
export 'src/isolate_application_server.dart';
|
||||
export 'src/isolate_supervisor.dart';
|
||||
export 'src/options.dart';
|
||||
export 'src/starter.dart';
|
237
packages/app/lib/src/application.dart
Normal file
237
packages/app/lib/src/application.dart
Normal file
|
@ -0,0 +1,237 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:protevus_application/application.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
export 'application_server.dart';
|
||||
export 'options.dart';
|
||||
export 'starter.dart';
|
||||
|
||||
/// This object starts and stops instances of your [ApplicationChannel].
|
||||
///
|
||||
/// An application object opens HTTP listeners that forward requests to instances of your [ApplicationChannel].
|
||||
/// It is unlikely that you need to use this class directly - the `conduit serve` command creates an application object
|
||||
/// on your behalf.
|
||||
class Application<T extends ApplicationChannel> {
|
||||
/// A list of isolates that this application supervises.
|
||||
List<ApplicationIsolateSupervisor> supervisors = [];
|
||||
|
||||
/// The [ApplicationServer] listening for HTTP requests while under test.
|
||||
///
|
||||
/// This property is only valid when an application is started via [startOnCurrentIsolate].
|
||||
late ApplicationServer server;
|
||||
|
||||
/// The [ApplicationChannel] handling requests while under test.
|
||||
///
|
||||
/// This property is only valid when an application is started via [startOnCurrentIsolate]. You use
|
||||
/// this value to access elements of your application channel during testing.
|
||||
T get channel => server.channel as T;
|
||||
|
||||
/// The logger that this application will write messages to.
|
||||
///
|
||||
/// This logger's name will appear as 'conduit'.
|
||||
Logger logger = Logger("conduit");
|
||||
|
||||
/// The options used to configure this application.
|
||||
///
|
||||
/// Changing these values once the application has started will have no effect.
|
||||
ApplicationOptions options = ApplicationOptions();
|
||||
|
||||
/// The duration to wait for each isolate during startup before failing.
|
||||
///
|
||||
/// A [TimeoutException] is thrown if an isolate fails to startup in this time period.
|
||||
///
|
||||
/// Defaults to 30 seconds.
|
||||
Duration isolateStartupTimeout = const Duration(seconds: 30);
|
||||
|
||||
/// Whether or not this application is running.
|
||||
///
|
||||
/// This will return true if [start]/[startOnCurrentIsolate] have been invoked and completed; i.e. this is the synchronous version of the [Future] returned by [start]/[startOnCurrentIsolate].
|
||||
///
|
||||
/// This value will return to false after [stop] has completed.
|
||||
bool get isRunning => _hasFinishedLaunching;
|
||||
bool _hasFinishedLaunching = false;
|
||||
ChannelRuntime get _runtime => RuntimeContext.current[T] as ChannelRuntime;
|
||||
|
||||
/// Starts this application, allowing it to handle HTTP requests.
|
||||
///
|
||||
/// This method spawns [numberOfInstances] isolates, instantiates your application channel
|
||||
/// for each of these isolates, and opens an HTTP listener that sends requests to these instances.
|
||||
///
|
||||
/// The [Future] returned from this method will complete once all isolates have successfully started
|
||||
/// and are available to handle requests.
|
||||
///
|
||||
/// If your application channel implements [ApplicationChannel.initializeApplication],
|
||||
/// it will be invoked prior to any isolate being spawned.
|
||||
///
|
||||
/// See also [startOnCurrentIsolate] for starting an application when running automated tests.
|
||||
Future start({int numberOfInstances = 1, bool consoleLogging = false}) async {
|
||||
if (supervisors.isNotEmpty) {
|
||||
throw StateError(
|
||||
"Application error. Cannot invoke 'start' on already running Conduit application.",
|
||||
);
|
||||
}
|
||||
|
||||
if (options.address == null) {
|
||||
if (options.isIpv6Only) {
|
||||
options.address = InternetAddress.anyIPv6;
|
||||
} else {
|
||||
options.address = InternetAddress.anyIPv4;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await _runtime.runGlobalInitialization(options);
|
||||
|
||||
for (var i = 0; i < numberOfInstances; i++) {
|
||||
final supervisor = await _spawn(
|
||||
this,
|
||||
options,
|
||||
i + 1,
|
||||
logger,
|
||||
isolateStartupTimeout,
|
||||
logToConsole: consoleLogging,
|
||||
);
|
||||
supervisors.add(supervisor);
|
||||
await supervisor.resume();
|
||||
}
|
||||
} catch (e, st) {
|
||||
logger.severe("$e", this, st);
|
||||
await stop().timeout(const Duration(seconds: 5));
|
||||
rethrow;
|
||||
}
|
||||
for (final sup in supervisors) {
|
||||
sup.sendPendingMessages();
|
||||
}
|
||||
_hasFinishedLaunching = true;
|
||||
}
|
||||
|
||||
/// Starts the application on the current isolate, and does not spawn additional isolates.
|
||||
///
|
||||
/// An application started in this way will run on the same isolate this method is invoked on.
|
||||
/// Performance is limited when running the application with this method; prefer to use [start].
|
||||
Future startOnCurrentIsolate() async {
|
||||
if (supervisors.isNotEmpty) {
|
||||
throw StateError(
|
||||
"Application error. Cannot invoke 'test' on already running Conduit application.",
|
||||
);
|
||||
}
|
||||
|
||||
options.address ??= InternetAddress.loopbackIPv4;
|
||||
|
||||
try {
|
||||
await _runtime.runGlobalInitialization(options);
|
||||
|
||||
server = ApplicationServer(_runtime.channelType, options, 1);
|
||||
|
||||
await server.start();
|
||||
_hasFinishedLaunching = true;
|
||||
} catch (e, st) {
|
||||
logger.severe("$e", this, st);
|
||||
await stop().timeout(const Duration(seconds: 5));
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the application from running.
|
||||
///
|
||||
/// Closes every isolate and their channel and stops listening for HTTP requests.
|
||||
/// The [ServiceRegistry] will close any of its resources.
|
||||
Future stop() async {
|
||||
_hasFinishedLaunching = false;
|
||||
await Future.wait(supervisors.map((s) => s.stop()))
|
||||
.onError((error, stackTrace) {
|
||||
if (error.runtimeType.toString() == 'LateError') {
|
||||
throw StateError(
|
||||
'Channel type $T was not loaded in the current isolate. Check that the class was declared and public.',
|
||||
);
|
||||
}
|
||||
throw error! as Error;
|
||||
});
|
||||
|
||||
try {
|
||||
await server.server.close(force: true);
|
||||
} catch (e) {
|
||||
logger.severe(e);
|
||||
}
|
||||
|
||||
_hasFinishedLaunching = false;
|
||||
supervisors = [];
|
||||
|
||||
logger.clearListeners();
|
||||
}
|
||||
|
||||
/// Creates an [APIDocument] from an [ApplicationChannel].
|
||||
///
|
||||
/// This method is called by the `conduit document` CLI.
|
||||
static Future<APIDocument> document(
|
||||
Type type,
|
||||
ApplicationOptions config,
|
||||
Map<String, dynamic> projectSpec,
|
||||
) async {
|
||||
final runtime = RuntimeContext.current[type] as ChannelRuntime;
|
||||
|
||||
await runtime.runGlobalInitialization(config);
|
||||
|
||||
final server = ApplicationServer(runtime.channelType, config, 1);
|
||||
|
||||
await server.channel.prepare();
|
||||
|
||||
final doc = await server.channel.documentAPI(projectSpec);
|
||||
|
||||
await server.channel.close();
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
Future<ApplicationIsolateSupervisor> _spawn(
|
||||
Application application,
|
||||
ApplicationOptions config,
|
||||
int identifier,
|
||||
Logger logger,
|
||||
Duration startupTimeout, {
|
||||
bool logToConsole = false,
|
||||
}) async {
|
||||
final receivePort = ReceivePort();
|
||||
|
||||
final libraryUri = _runtime.libraryUri;
|
||||
final typeName = _runtime.name;
|
||||
final entryPoint = _runtime.isolateEntryPoint;
|
||||
|
||||
final initialMessage = ApplicationInitialServerMessage(
|
||||
typeName,
|
||||
libraryUri,
|
||||
config,
|
||||
identifier,
|
||||
receivePort.sendPort,
|
||||
logToConsole: logToConsole,
|
||||
);
|
||||
final isolate =
|
||||
await Isolate.spawn(entryPoint, initialMessage, paused: true);
|
||||
|
||||
return ApplicationIsolateSupervisor(
|
||||
application,
|
||||
isolate,
|
||||
receivePort,
|
||||
identifier,
|
||||
logger,
|
||||
startupTimeout: startupTimeout,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Thrown when an application encounters an exception during startup.
|
||||
///
|
||||
/// Contains the original exception that halted startup.
|
||||
class ApplicationStartupException implements Exception {
|
||||
ApplicationStartupException(this.originalException);
|
||||
|
||||
dynamic originalException;
|
||||
|
||||
@override
|
||||
String toString() => originalException.toString();
|
||||
}
|
123
packages/app/lib/src/application_server.dart
Normal file
123
packages/app/lib/src/application_server.dart
Normal file
|
@ -0,0 +1,123 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:protevus_application/application.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Listens for HTTP requests and delivers them to its [ApplicationChannel] instance.
|
||||
///
|
||||
/// A Conduit application creates instances of this type to pair an HTTP server and an
|
||||
/// instance of an [ApplicationChannel] subclass. Instances are created by [Application]
|
||||
/// and shouldn't be created otherwise.
|
||||
class ApplicationServer {
|
||||
/// Creates a new server.
|
||||
///
|
||||
/// You should not need to invoke this method directly.
|
||||
ApplicationServer(this.channelType, this.options, this.identifier) {
|
||||
channel = (RuntimeContext.current[channelType] as ChannelRuntime)
|
||||
.instantiateChannel()
|
||||
..server = this
|
||||
..options = options;
|
||||
}
|
||||
|
||||
/// The configuration this instance used to start its [channel].
|
||||
ApplicationOptions options;
|
||||
|
||||
/// The underlying [HttpServer].
|
||||
late final HttpServer server;
|
||||
|
||||
/// The instance of [ApplicationChannel] serving requests.
|
||||
late ApplicationChannel channel;
|
||||
|
||||
/// The cached entrypoint of [channel].
|
||||
late Controller entryPoint;
|
||||
|
||||
final Type channelType;
|
||||
|
||||
/// Target for sending messages to other [ApplicationChannel.messageHub]s.
|
||||
///
|
||||
/// Events are added to this property by instances of [ApplicationMessageHub] and should not otherwise be used.
|
||||
EventSink<dynamic>? hubSink;
|
||||
|
||||
/// Whether or not this server requires an HTTPS listener.
|
||||
bool get requiresHTTPS => _requiresHTTPS;
|
||||
bool _requiresHTTPS = false;
|
||||
|
||||
/// The unique identifier of this instance.
|
||||
///
|
||||
/// Each instance has its own identifier, a numeric value starting at 1, to identify it
|
||||
/// among other instances.
|
||||
int identifier;
|
||||
|
||||
/// The logger of this instance
|
||||
Logger get logger => Logger("conduit");
|
||||
|
||||
/// Starts this instance, allowing it to receive HTTP requests.
|
||||
///
|
||||
/// Do not invoke this method directly.
|
||||
Future start({bool shareHttpServer = false}) async {
|
||||
logger.fine("ApplicationServer($identifier).start entry");
|
||||
|
||||
await channel.prepare();
|
||||
|
||||
entryPoint = channel.entryPoint;
|
||||
entryPoint.didAddToChannel();
|
||||
|
||||
logger.fine("ApplicationServer($identifier).start binding HTTP");
|
||||
final securityContext = channel.securityContext;
|
||||
if (securityContext != null) {
|
||||
_requiresHTTPS = true;
|
||||
|
||||
server = await HttpServer.bindSecure(
|
||||
options.address,
|
||||
options.port,
|
||||
securityContext,
|
||||
requestClientCertificate: options.isUsingClientCertificate,
|
||||
v6Only: options.isIpv6Only,
|
||||
shared: shareHttpServer,
|
||||
);
|
||||
} else {
|
||||
_requiresHTTPS = false;
|
||||
|
||||
server = await HttpServer.bind(
|
||||
options.address,
|
||||
options.port,
|
||||
v6Only: options.isIpv6Only,
|
||||
shared: shareHttpServer,
|
||||
);
|
||||
}
|
||||
|
||||
logger.fine("ApplicationServer($identifier).start bound HTTP");
|
||||
return didOpen();
|
||||
}
|
||||
|
||||
/// Closes this HTTP server and channel.
|
||||
Future close() async {
|
||||
logger.fine("ApplicationServer($identifier).close Closing HTTP listener");
|
||||
await server.close(force: true);
|
||||
logger.fine("ApplicationServer($identifier).close Closing channel");
|
||||
await channel.close();
|
||||
|
||||
// This is actually closed by channel.messageHub.close, but this shuts up the analyzer.
|
||||
hubSink?.close();
|
||||
logger.fine("ApplicationServer($identifier).close Closing complete");
|
||||
}
|
||||
|
||||
/// Invoked when this server becomes ready receive requests.
|
||||
///
|
||||
/// [ApplicationChannel.willStartReceivingRequests] is invoked after this opening has completed.
|
||||
Future didOpen() async {
|
||||
server.serverHeader = "conduit/$identifier";
|
||||
|
||||
logger.fine("ApplicationServer($identifier).didOpen start listening");
|
||||
server.map((baseReq) => Request(baseReq)).listen(entryPoint.receive);
|
||||
|
||||
channel.willStartReceivingRequests();
|
||||
logger.info("Server conduit/$identifier started.");
|
||||
}
|
||||
|
||||
void sendApplicationEvent(dynamic event) {
|
||||
// By default, do nothing
|
||||
}
|
||||
}
|
290
packages/app/lib/src/channel.dart
Normal file
290
packages/app/lib/src/channel.dart
Normal file
|
@ -0,0 +1,290 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_application/application.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';
|
||||
|
||||
/// An object that defines the behavior specific to your application.
|
||||
///
|
||||
/// You create a subclass of [ApplicationChannel] to initialize your application's services and define how HTTP requests are handled by your application.
|
||||
/// There *must* only be one subclass in an application and it must be visible to your application library file, e.g., 'package:my_app/my_app.dart'.
|
||||
///
|
||||
/// You must implement [entryPoint] to define the controllers that comprise your application channel. Most applications will
|
||||
/// also override [prepare] to read configuration values and initialize services. Some applications will provide an [initializeApplication]
|
||||
/// method to do global startup tasks.
|
||||
///
|
||||
/// When your application is started, an instance of your application channel is created for each isolate (see [Application.start]). Each instance
|
||||
/// is a replica of your application that runs in its own memory isolated thread.
|
||||
abstract class ApplicationChannel implements APIComponentDocumenter {
|
||||
/// You implement this method to provide global initialization for your application.
|
||||
///
|
||||
/// Most of your application initialization code is written in [prepare], which is invoked for each isolate. For initialization that
|
||||
/// needs to occur once per application start, you must provide an implementation for this method. This method is invoked prior
|
||||
/// to any isolates being spawned.
|
||||
///
|
||||
/// You may alter [options] in this method and those changes will be available in each instance's [options]. To pass arbitrary data
|
||||
/// to each of your isolates at startup, add that data to [ApplicationOptions.context].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// class MyChannel extends ApplicationChannel {
|
||||
/// static Future initializeApplication(ApplicationOptions options) async {
|
||||
/// options.context["runtimeOption"] = "foo";
|
||||
/// }
|
||||
///
|
||||
/// Future prepare() async {
|
||||
/// if (options.context["runtimeOption"] == "foo") {
|
||||
/// // do something
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
///
|
||||
/// Do not configure objects like [CodecRegistry], [CORSPolicy.defaultPolicy] or any other value that isn't explicitly passed through [options].
|
||||
///
|
||||
/// * Note that static methods are not inherited in Dart and therefore you are not overriding this method. The declaration of this method in the base [ApplicationChannel] class
|
||||
/// is for documentation purposes.
|
||||
static Future initializeApplication(ApplicationOptions options) async {}
|
||||
|
||||
/// The logger that this object will write messages to.
|
||||
///
|
||||
/// This logger's name appears as 'conduit'.
|
||||
Logger get logger => Logger("conduit");
|
||||
|
||||
/// The [ApplicationServer] that sends HTTP requests to this object.
|
||||
ApplicationServer get server => _server;
|
||||
|
||||
set server(ApplicationServer server) {
|
||||
_server = server;
|
||||
messageHub._outboundController.stream.listen(server.sendApplicationEvent);
|
||||
server.hubSink = messageHub._inboundController.sink;
|
||||
}
|
||||
|
||||
/// Use this object to send data to channels running on other isolates.
|
||||
///
|
||||
/// You use this object to synchronize state across the isolates of an application. Any data sent
|
||||
/// through this object will be received by every other channel in your application (except the one that sent it).
|
||||
final ApplicationMessageHub messageHub = ApplicationMessageHub();
|
||||
|
||||
/// The context used for setting up HTTPS in an application.
|
||||
///
|
||||
/// If this value is non-null, the [server] receiving HTTP requests will only accept requests over HTTPS.
|
||||
///
|
||||
/// By default, this value is null. If the [ApplicationOptions] provided to the application are configured to
|
||||
/// reference a private key and certificate file, this value is derived from that information. You may override
|
||||
/// this method to provide an alternative means to creating a [SecurityContext].
|
||||
SecurityContext? get securityContext {
|
||||
if (options?.certificateFilePath == null ||
|
||||
options?.privateKeyFilePath == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return SecurityContext()
|
||||
..useCertificateChain(options!.certificateFilePath!)
|
||||
..usePrivateKey(options!.privateKeyFilePath!);
|
||||
}
|
||||
|
||||
/// The configuration options used to start the application this channel belongs to.
|
||||
///
|
||||
/// These options are set when starting the application. Changes to this object have no effect
|
||||
/// on other isolates.
|
||||
ApplicationOptions? options;
|
||||
|
||||
/// You implement this accessor to define how HTTP requests are handled by your application.
|
||||
///
|
||||
/// You must implement this method to return the first controller that will handle an HTTP request. Additional controllers
|
||||
/// are linked to the first controller to create the entire flow of your application's request handling logic. This method
|
||||
/// is invoked during startup and controllers cannot be changed after it is invoked. This method is always invoked after
|
||||
/// [prepare].
|
||||
///
|
||||
/// In most applications, the first controller is a [Router]. Example:
|
||||
///
|
||||
/// @override
|
||||
/// Controller get entryPoint {
|
||||
/// final router = Router();
|
||||
/// router.route("/path").link(() => PathController());
|
||||
/// return router;
|
||||
/// }
|
||||
Controller get entryPoint;
|
||||
|
||||
late ApplicationServer _server;
|
||||
|
||||
/// You override this method to perform initialization tasks.
|
||||
///
|
||||
/// This method allows this instance to perform any initialization (other than setting up the [entryPoint]). This method
|
||||
/// is often used to set up services that [Controller]s use to fulfill their duties. This method is invoked
|
||||
/// prior to [entryPoint], so that the services it creates can be injected into [Controller]s.
|
||||
///
|
||||
/// By default, this method does nothing.
|
||||
Future prepare() async {}
|
||||
|
||||
/// You override this method to perform initialization tasks that occur after [entryPoint] has been established.
|
||||
///
|
||||
/// Override this method to take action just before [entryPoint] starts receiving requests. By default, does nothing.
|
||||
void willStartReceivingRequests() {}
|
||||
|
||||
/// You override this method to release any resources created in [prepare].
|
||||
///
|
||||
/// This method is invoked when the owning [Application] is stopped. It closes open ports
|
||||
/// that this channel was using so that the application can be properly shut down.
|
||||
///
|
||||
/// Prefer to use [ServiceRegistry] instead of overriding this method.
|
||||
///
|
||||
/// If you do override this method, you must call the super implementation.
|
||||
@mustCallSuper
|
||||
Future close() async {
|
||||
logger.fine(
|
||||
"ApplicationChannel(${server.identifier}).close: closing messageHub",
|
||||
);
|
||||
await messageHub.close();
|
||||
}
|
||||
|
||||
/// Creates an OpenAPI document for the components and paths in this channel.
|
||||
///
|
||||
/// This method invokes [entryPoint] and [prepare] before starting the documentation process.
|
||||
///
|
||||
/// The documentation process first invokes [documentComponents] on this channel. Every controller in the channel will have its
|
||||
/// [documentComponents] methods invoked. Any declared property
|
||||
/// of this channel that implements [APIComponentDocumenter] will have its [documentComponents]
|
||||
/// method invoked. If there services that are part of the application, but not stored as properties of this channel, you may override
|
||||
/// [documentComponents] in your subclass to add them. You must call the superclass' implementation of [documentComponents].
|
||||
///
|
||||
/// After components have been documented, [APIOperationDocumenter.documentPaths] is invoked on [entryPoint]. The controllers
|
||||
/// of the channel will add paths and operations to the document during this process.
|
||||
///
|
||||
/// This method should not be overridden.
|
||||
///
|
||||
/// [projectSpec] should contain the keys `name`, `version` and `description`.
|
||||
Future<APIDocument> documentAPI(Map<String, dynamic> projectSpec) async {
|
||||
final doc = APIDocument()..components = APIComponents();
|
||||
final root = entryPoint;
|
||||
root.didAddToChannel();
|
||||
|
||||
final context = APIDocumentContext(doc);
|
||||
documentComponents(context);
|
||||
|
||||
doc.paths = root.documentPaths(context);
|
||||
|
||||
doc.info = APIInfo(
|
||||
projectSpec["name"] as String?,
|
||||
projectSpec["version"] as String?,
|
||||
description: projectSpec["description"] as String?,
|
||||
);
|
||||
|
||||
await context.finalize();
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
@mustCallSuper
|
||||
@override
|
||||
void documentComponents(APIDocumentContext registry) {
|
||||
entryPoint.documentComponents(registry);
|
||||
|
||||
(RuntimeContext.current[runtimeType] as ChannelRuntime)
|
||||
.getDocumentableChannelComponents(this)
|
||||
.forEach((component) {
|
||||
component.documentComponents(registry);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// An object that sends and receives messages between [ApplicationChannel]s.
|
||||
///
|
||||
/// You use this object to share information between isolates. Each [ApplicationChannel] has a property of this type. A message sent through this object
|
||||
/// is received by every other channel through its hub.
|
||||
///
|
||||
/// To receive messages in a hub, add a listener via [listen]. To send messages, use [add].
|
||||
///
|
||||
/// For example, an application may want to send data to every connected websocket. A reference to each websocket
|
||||
/// is only known to the isolate it established a connection on. This data must be sent to each isolate so that each websocket
|
||||
/// connected to that isolate can send the data:
|
||||
///
|
||||
/// router.route("/broadcast").linkFunction((req) async {
|
||||
/// var message = await req.body.decodeAsString();
|
||||
/// websocketsOnThisIsolate.forEach((s) => s.add(message);
|
||||
/// messageHub.add({"event": "broadcastMessage", "data": message});
|
||||
/// return Response.accepted();
|
||||
/// });
|
||||
///
|
||||
/// messageHub.listen((event) {
|
||||
/// if (event is Map && event["event"] == "broadcastMessage") {
|
||||
/// websocketsOnThisIsolate.forEach((s) => s.add(event["data"]);
|
||||
/// }
|
||||
/// });
|
||||
class ApplicationMessageHub extends Stream<dynamic> implements Sink<dynamic> {
|
||||
final Logger _logger = Logger("conduit");
|
||||
final StreamController<dynamic> _outboundController =
|
||||
StreamController<dynamic>();
|
||||
final StreamController<dynamic> _inboundController =
|
||||
StreamController<dynamic>.broadcast();
|
||||
|
||||
/// Adds a listener for messages from other hubs.
|
||||
///
|
||||
/// You use this method to add listeners for messages from other hubs.
|
||||
/// When another hub [add]s a message, this hub will receive it on [onData].
|
||||
///
|
||||
/// [onError], if provided, will be invoked when this isolate tries to [add] invalid data. Only the isolate
|
||||
/// that failed to send the data will receive [onError] events.
|
||||
@override
|
||||
StreamSubscription<dynamic> listen(
|
||||
void Function(dynamic event)? onData, {
|
||||
Function? onError,
|
||||
void Function()? onDone,
|
||||
bool? cancelOnError = false,
|
||||
}) =>
|
||||
_inboundController.stream.listen(
|
||||
onData,
|
||||
onError: onError ??
|
||||
((err, StackTrace st) =>
|
||||
_logger.severe("ApplicationMessageHub error", err, st)),
|
||||
onDone: onDone,
|
||||
cancelOnError: cancelOnError,
|
||||
);
|
||||
|
||||
/// Sends a message to all other hubs.
|
||||
///
|
||||
/// [event] will be delivered to all other isolates that have set up a callback for [listen].
|
||||
///
|
||||
/// [event] must be isolate-safe data - in general, this means it may not be or contain a closure. Consult the API reference `dart:isolate` for more details. If [event]
|
||||
/// is not isolate-safe data, an error is delivered to [listen] on this isolate.
|
||||
@override
|
||||
void add(dynamic event) {
|
||||
_outboundController.sink.add(event);
|
||||
}
|
||||
|
||||
@override
|
||||
Future close() async {
|
||||
if (!_outboundController.hasListener) {
|
||||
_outboundController.stream.listen(null);
|
||||
}
|
||||
|
||||
if (!_inboundController.hasListener) {
|
||||
_inboundController.stream.listen(null);
|
||||
}
|
||||
|
||||
await _outboundController.close();
|
||||
await _inboundController.close();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ChannelRuntime {
|
||||
Iterable<APIComponentDocumenter> getDocumentableChannelComponents(
|
||||
ApplicationChannel channel,
|
||||
);
|
||||
|
||||
Type get channelType;
|
||||
|
||||
String get name;
|
||||
Uri get libraryUri;
|
||||
IsolateEntryFunction get isolateEntryPoint;
|
||||
|
||||
ApplicationChannel instantiateChannel();
|
||||
|
||||
Future runGlobalInitialization(ApplicationOptions config);
|
||||
}
|
101
packages/app/lib/src/isolate_application_server.dart
Normal file
101
packages/app/lib/src/isolate_application_server.dart
Normal file
|
@ -0,0 +1,101 @@
|
|||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:protevus_application/application.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
class ApplicationIsolateServer extends ApplicationServer {
|
||||
ApplicationIsolateServer(
|
||||
Type channelType,
|
||||
ApplicationOptions configuration,
|
||||
int identifier,
|
||||
this.supervisingApplicationPort, {
|
||||
bool logToConsole = false,
|
||||
}) : super(channelType, configuration, identifier) {
|
||||
if (logToConsole) {
|
||||
hierarchicalLoggingEnabled = true;
|
||||
logger.level = Level.ALL;
|
||||
// ignore: avoid_print
|
||||
logger.onRecord.listen(print);
|
||||
}
|
||||
supervisingReceivePort = ReceivePort();
|
||||
supervisingReceivePort.listen(listener);
|
||||
|
||||
logger
|
||||
.fine("ApplicationIsolateServer($identifier) listening, sending port");
|
||||
supervisingApplicationPort.send(supervisingReceivePort.sendPort);
|
||||
}
|
||||
|
||||
SendPort supervisingApplicationPort;
|
||||
late ReceivePort supervisingReceivePort;
|
||||
|
||||
@override
|
||||
Future start({bool shareHttpServer = false}) async {
|
||||
final result = await super.start(shareHttpServer: shareHttpServer);
|
||||
logger.fine(
|
||||
"ApplicationIsolateServer($identifier) started, sending listen message",
|
||||
);
|
||||
supervisingApplicationPort
|
||||
.send(ApplicationIsolateSupervisor.messageKeyListening);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@override
|
||||
void sendApplicationEvent(dynamic event) {
|
||||
try {
|
||||
supervisingApplicationPort.send(MessageHubMessage(event));
|
||||
} catch (e, st) {
|
||||
hubSink?.addError(e, st);
|
||||
}
|
||||
}
|
||||
|
||||
void listener(dynamic message) {
|
||||
if (message == ApplicationIsolateSupervisor.messageKeyStop) {
|
||||
stop();
|
||||
} else if (message is MessageHubMessage) {
|
||||
hubSink?.add(message.payload);
|
||||
}
|
||||
}
|
||||
|
||||
Future stop() async {
|
||||
supervisingReceivePort.close();
|
||||
logger.fine("ApplicationIsolateServer($identifier) closing server");
|
||||
await close();
|
||||
logger.fine("ApplicationIsolateServer($identifier) did close server");
|
||||
logger.clearListeners();
|
||||
logger.fine(
|
||||
"ApplicationIsolateServer($identifier) sending stop acknowledgement",
|
||||
);
|
||||
supervisingApplicationPort
|
||||
.send(ApplicationIsolateSupervisor.messageKeyStop);
|
||||
}
|
||||
}
|
||||
|
||||
typedef IsolateEntryFunction = void Function(
|
||||
ApplicationInitialServerMessage message,
|
||||
);
|
||||
|
||||
class ApplicationInitialServerMessage {
|
||||
ApplicationInitialServerMessage(
|
||||
this.streamTypeName,
|
||||
this.streamLibraryURI,
|
||||
this.configuration,
|
||||
this.identifier,
|
||||
this.parentMessagePort, {
|
||||
this.logToConsole = false,
|
||||
});
|
||||
|
||||
String streamTypeName;
|
||||
Uri streamLibraryURI;
|
||||
ApplicationOptions configuration;
|
||||
SendPort parentMessagePort;
|
||||
int identifier;
|
||||
bool logToConsole = false;
|
||||
}
|
||||
|
||||
class MessageHubMessage {
|
||||
MessageHubMessage(this.payload);
|
||||
|
||||
dynamic payload;
|
||||
}
|
147
packages/app/lib/src/isolate_supervisor.dart
Normal file
147
packages/app/lib/src/isolate_supervisor.dart
Normal file
|
@ -0,0 +1,147 @@
|
|||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:protevus_application/application.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Represents the supervision of a [ApplicationIsolateServer].
|
||||
///
|
||||
/// You should not use this class directly.
|
||||
class ApplicationIsolateSupervisor {
|
||||
/// Create an instance of [ApplicationIsolateSupervisor].
|
||||
ApplicationIsolateSupervisor(
|
||||
this.supervisingApplication,
|
||||
this.isolate,
|
||||
this.receivePort,
|
||||
this.identifier,
|
||||
this.logger, {
|
||||
this.startupTimeout = const Duration(seconds: 30),
|
||||
});
|
||||
|
||||
/// The [Isolate] being supervised.
|
||||
final Isolate isolate;
|
||||
|
||||
/// The [ReceivePort] for which messages coming from [isolate] will be received.
|
||||
final ReceivePort receivePort;
|
||||
|
||||
/// A numeric identifier for the isolate relative to the [Application].
|
||||
final int identifier;
|
||||
|
||||
final Duration startupTimeout;
|
||||
|
||||
/// A reference to the owning [Application]
|
||||
Application supervisingApplication;
|
||||
|
||||
/// A reference to the [Logger] used by the [supervisingApplication].
|
||||
Logger logger;
|
||||
|
||||
final List<MessageHubMessage> _pendingMessageQueue = [];
|
||||
|
||||
bool get _isLaunching => !_launchCompleter.isCompleted;
|
||||
late SendPort _serverSendPort;
|
||||
late Completer _launchCompleter;
|
||||
Completer? _stopCompleter;
|
||||
|
||||
static const String messageKeyStop = "_MessageStop";
|
||||
static const String messageKeyListening = "_MessageListening";
|
||||
|
||||
/// Resumes the [Isolate] being supervised.
|
||||
Future resume() {
|
||||
_launchCompleter = Completer();
|
||||
receivePort.listen(listener);
|
||||
|
||||
isolate.setErrorsFatal(false);
|
||||
isolate.addErrorListener(receivePort.sendPort);
|
||||
logger.fine(
|
||||
"ApplicationIsolateSupervisor($identifier).resume will resume isolate",
|
||||
);
|
||||
isolate.resume(isolate.pauseCapability!);
|
||||
|
||||
return _launchCompleter.future.timeout(
|
||||
startupTimeout,
|
||||
onTimeout: () {
|
||||
logger.fine(
|
||||
"ApplicationIsolateSupervisor($identifier).resume timed out waiting for isolate start",
|
||||
);
|
||||
throw TimeoutException(
|
||||
"Isolate ($identifier) failed to launch in $startupTimeout seconds. "
|
||||
"There may be an error with your application or Application.isolateStartupTimeout needs to be increased.");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops the [Isolate] being supervised.
|
||||
Future stop() async {
|
||||
_stopCompleter = Completer();
|
||||
logger.fine(
|
||||
"ApplicationIsolateSupervisor($identifier).stop sending stop to supervised isolate",
|
||||
);
|
||||
_serverSendPort.send(messageKeyStop);
|
||||
|
||||
try {
|
||||
await _stopCompleter!.future.timeout(const Duration(seconds: 5));
|
||||
} on TimeoutException {
|
||||
logger.severe(
|
||||
"Isolate ($identifier) not responding to stop message, terminating.",
|
||||
);
|
||||
} finally {
|
||||
isolate.kill();
|
||||
}
|
||||
|
||||
receivePort.close();
|
||||
}
|
||||
|
||||
void listener(dynamic message) {
|
||||
if (message is SendPort) {
|
||||
_serverSendPort = message;
|
||||
} else if (message == messageKeyListening) {
|
||||
_launchCompleter.complete();
|
||||
logger.fine(
|
||||
"ApplicationIsolateSupervisor($identifier) isolate listening acknowledged",
|
||||
);
|
||||
} else if (message == messageKeyStop) {
|
||||
logger.fine(
|
||||
"ApplicationIsolateSupervisor($identifier) stop message acknowledged",
|
||||
);
|
||||
receivePort.close();
|
||||
|
||||
_stopCompleter!.complete();
|
||||
_stopCompleter = null;
|
||||
} else if (message is List) {
|
||||
logger.fine(
|
||||
"ApplicationIsolateSupervisor($identifier) received isolate error ${message.first}",
|
||||
);
|
||||
final stacktrace = StackTrace.fromString(message.last as String);
|
||||
_handleIsolateException(message.first, stacktrace);
|
||||
} else if (message is MessageHubMessage) {
|
||||
if (!supervisingApplication.isRunning) {
|
||||
_pendingMessageQueue.add(message);
|
||||
} else {
|
||||
_sendMessageToOtherSupervisors(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void sendPendingMessages() {
|
||||
final list = List<MessageHubMessage>.from(_pendingMessageQueue);
|
||||
_pendingMessageQueue.clear();
|
||||
list.forEach(_sendMessageToOtherSupervisors);
|
||||
}
|
||||
|
||||
void _sendMessageToOtherSupervisors(MessageHubMessage message) {
|
||||
supervisingApplication.supervisors
|
||||
.where((sup) => sup != this)
|
||||
.forEach((supervisor) {
|
||||
supervisor._serverSendPort.send(message);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleIsolateException(dynamic error, StackTrace stacktrace) {
|
||||
if (_isLaunching) {
|
||||
final appException = ApplicationStartupException(error);
|
||||
_launchCompleter.completeError(appException, stacktrace);
|
||||
} else {
|
||||
logger.severe("Uncaught exception in isolate.", error, stacktrace);
|
||||
}
|
||||
}
|
||||
}
|
106
packages/app/lib/src/options.dart
Normal file
106
packages/app/lib/src/options.dart
Normal file
|
@ -0,0 +1,106 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:args/args.dart';
|
||||
import 'package:protevus_application/application.dart';
|
||||
|
||||
/// An object that contains configuration values for an [Application].
|
||||
///
|
||||
/// You use this object in an [ApplicationChannel] to manage external configuration data for your application.
|
||||
class ApplicationOptions {
|
||||
/// The absolute path of the configuration file for this application.
|
||||
///
|
||||
/// This path is provided when an application is started by the `--config-path` option to `conduit serve`.
|
||||
/// You may load the file at this path in [ApplicationChannel] to use configuration values.
|
||||
String? configurationFilePath;
|
||||
|
||||
/// The address to listen for HTTP requests on.
|
||||
///
|
||||
/// By default, this address will default to 'any' address (0.0.0.0). If [isIpv6Only] is true,
|
||||
/// 'any' will be any IPv6 address, otherwise, it will be any IPv4 or IPv6 address.
|
||||
///
|
||||
/// This value may be an [InternetAddress] or a [String].
|
||||
dynamic address;
|
||||
|
||||
/// The port to listen for HTTP requests on.
|
||||
///
|
||||
/// Defaults to 8888.
|
||||
int port = 8888;
|
||||
|
||||
/// Whether or not the application should only receive connections over IPv6.
|
||||
///
|
||||
/// Defaults to false. This flag impacts the default value of the [address] property.
|
||||
bool isIpv6Only = false;
|
||||
|
||||
/// Whether or not the application's request controllers should use client-side HTTPS certificates.
|
||||
///
|
||||
/// Defaults to false.
|
||||
bool isUsingClientCertificate = false;
|
||||
|
||||
/// The path to a SSL certificate.
|
||||
///
|
||||
/// If specified - along with [privateKeyFilePath] - an [Application] will only allow secure connections over HTTPS.
|
||||
/// This value is often set through the `--ssl-certificate-path` command line option of `conduit serve`. For finer control
|
||||
/// over how HTTPS is configured for an application, see [ApplicationChannel.securityContext].
|
||||
String? certificateFilePath;
|
||||
|
||||
/// The path to a private key.
|
||||
///
|
||||
/// If specified - along with [certificateFilePath] - an [Application] will only allow secure connections over HTTPS.
|
||||
/// This value is often set through the `--ssl-key-path` command line option of `conduit serve`. For finer control
|
||||
/// over how HTTPS is configured for an application, see [ApplicationChannel.securityContext].
|
||||
String? privateKeyFilePath;
|
||||
|
||||
/// Contextual configuration values for each [ApplicationChannel].
|
||||
///
|
||||
/// This is a user-specific set of configuration options provided by [ApplicationChannel.initializeApplication].
|
||||
/// Each instance of [ApplicationChannel] has access to these values if set.
|
||||
final Map<String, dynamic> context = {};
|
||||
|
||||
static final parser = ArgParser()
|
||||
..addOption(
|
||||
"address",
|
||||
abbr: "a",
|
||||
help: "The address to listen on. See HttpServer.bind for more details."
|
||||
" Using the default will listen on any address.",
|
||||
)
|
||||
..addOption(
|
||||
"config-path",
|
||||
abbr: "c",
|
||||
help:
|
||||
"The path to a configuration file. This File is available in the ApplicationOptions "
|
||||
"for a ApplicationChannel to use to read application-specific configuration values. Relative paths are relative to [directory].",
|
||||
defaultsTo: "config.yaml",
|
||||
)
|
||||
..addOption(
|
||||
"isolates",
|
||||
abbr: "n",
|
||||
help: "Number of isolates handling requests.",
|
||||
)
|
||||
..addOption(
|
||||
"port",
|
||||
abbr: "p",
|
||||
help: "The port number to listen for HTTP requests on.",
|
||||
defaultsTo: "8888",
|
||||
)
|
||||
..addFlag(
|
||||
"ipv6-only",
|
||||
help: "Limits listening to IPv6 connections only.",
|
||||
negatable: false,
|
||||
)
|
||||
..addOption(
|
||||
"ssl-certificate-path",
|
||||
help:
|
||||
"The path to an SSL certicate file. If provided along with --ssl-certificate-path, the application will be HTTPS-enabled.",
|
||||
)
|
||||
..addOption(
|
||||
"ssl-key-path",
|
||||
help:
|
||||
"The path to an SSL private key file. If provided along with --ssl-certificate-path, the application will be HTTPS-enabled.",
|
||||
)
|
||||
..addOption(
|
||||
"timeout",
|
||||
help: "Number of seconds to wait to ensure startup succeeded.",
|
||||
defaultsTo: "45",
|
||||
)
|
||||
..addFlag("help");
|
||||
}
|
32
packages/app/lib/src/starter.dart
Normal file
32
packages/app/lib/src/starter.dart
Normal file
|
@ -0,0 +1,32 @@
|
|||
import 'dart:async';
|
||||
import 'dart:isolate';
|
||||
|
||||
import 'package:protevus_application/application.dart';
|
||||
|
||||
/*
|
||||
Warning: do not remove. This method is invoked by a generated script.
|
||||
|
||||
*/
|
||||
Future startApplication<T extends ApplicationChannel>(
|
||||
Application<T> app,
|
||||
int isolateCount,
|
||||
SendPort parentPort,
|
||||
) async {
|
||||
final port = ReceivePort();
|
||||
|
||||
port.listen((msg) {
|
||||
if (msg["command"] == "stop") {
|
||||
port.close();
|
||||
app.stop().then((_) {
|
||||
parentPort.send({"status": "stopped"});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (isolateCount == 0) {
|
||||
await app.startOnCurrentIsolate();
|
||||
} else {
|
||||
await app.start(numberOfInstances: isolateCount);
|
||||
}
|
||||
parentPort.send({"status": "ok", "port": port.sendPort});
|
||||
}
|
23
packages/app/pubspec.yaml
Normal file
23
packages/app/pubspec.yaml
Normal file
|
@ -0,0 +1,23 @@
|
|||
name: protevus_application
|
||||
description: The Application Package for the Protevus Platform
|
||||
version: 0.0.1
|
||||
homepage: https://protevus.com
|
||||
documentation: https://docs.protevus.com
|
||||
repository: https://git.protevus.com/protevus/platform
|
||||
|
||||
environment:
|
||||
sdk: ^3.4.2
|
||||
|
||||
# Add regular dependencies here.
|
||||
dependencies:
|
||||
args: ^2.4.2
|
||||
logging: ^1.2.0
|
||||
meta: ^1.12.0
|
||||
protevus_runtime: ^0.0.1
|
||||
protevus_openapi: ^0.0.1
|
||||
protevus_http: ^0.0.1
|
||||
# path: ^1.8.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^3.0.0
|
||||
test: ^1.24.0
|
22
packages/auth/lib/auth.dart
Normal file
22
packages/auth/lib/auth.dart
Normal file
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
library;
|
||||
|
||||
export 'src/auth.dart';
|
||||
export 'src/auth_code_controller.dart';
|
||||
export 'src/auth_controller.dart';
|
||||
export 'src/auth_redirect_controller.dart';
|
||||
export 'src/authorization_parser.dart';
|
||||
export 'src/authorization_server.dart';
|
||||
export 'src/authorizer.dart';
|
||||
export 'src/exceptions.dart';
|
||||
export 'src/objects.dart';
|
||||
export 'src/protocols.dart';
|
||||
export 'src/validator.dart';
|
70
packages/auth/lib/src/auth.dart
Normal file
70
packages/auth/lib/src/auth.dart
Normal file
|
@ -0,0 +1,70 @@
|
|||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_hashing/hashing.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
export 'auth_code_controller.dart';
|
||||
export 'auth_controller.dart';
|
||||
export 'auth_redirect_controller.dart';
|
||||
export 'authorization_parser.dart';
|
||||
export 'authorization_server.dart';
|
||||
export 'authorizer.dart';
|
||||
export 'exceptions.dart';
|
||||
export 'objects.dart';
|
||||
export 'protocols.dart';
|
||||
export 'validator.dart';
|
||||
|
||||
/// A utility method to generate a password hash using the PBKDF2 scheme.
|
||||
///
|
||||
///
|
||||
String generatePasswordHash(
|
||||
String password,
|
||||
String salt, {
|
||||
int hashRounds = 1000,
|
||||
int hashLength = 32,
|
||||
Hash? hashFunction,
|
||||
}) {
|
||||
final generator = PBKDF2(hashAlgorithm: hashFunction ?? sha256);
|
||||
return generator.generateBase64Key(password, salt, hashRounds, hashLength);
|
||||
}
|
||||
|
||||
/// A utility method to generate a random base64 salt.
|
||||
///
|
||||
///
|
||||
String generateRandomSalt({int hashLength = 32}) {
|
||||
return generateAsBase64String(hashLength);
|
||||
}
|
||||
|
||||
/// A utility method to generate a ClientID and Client Secret Pair.
|
||||
///
|
||||
/// [secret] may be null. If secret is null, the return value is a 'public' client. Otherwise, the
|
||||
/// client is 'confidential'. Public clients must not include a client secret when sent to the
|
||||
/// authorization server. Confidential clients must include the secret in all requests. Use public clients when
|
||||
/// the source code of the client application is visible, i.e. a JavaScript browser application.
|
||||
///
|
||||
/// Any client that allows the authorization code flow must include [redirectURI].
|
||||
///
|
||||
/// Note that [secret] is hashed with a randomly generated salt, and therefore cannot be retrieved
|
||||
/// later. The plain-text secret must be stored securely elsewhere.
|
||||
AuthClient generateAPICredentialPair(
|
||||
String clientID,
|
||||
String? secret, {
|
||||
String? redirectURI,
|
||||
int hashLength = 32,
|
||||
int hashRounds = 1000,
|
||||
Hash? hashFunction,
|
||||
}) {
|
||||
if (secret == null) {
|
||||
return AuthClient.public(clientID, redirectURI: redirectURI);
|
||||
}
|
||||
|
||||
final salt = generateRandomSalt(hashLength: hashLength);
|
||||
final hashed = generatePasswordHash(
|
||||
secret,
|
||||
salt,
|
||||
hashRounds: hashRounds,
|
||||
hashLength: hashLength,
|
||||
hashFunction: hashFunction,
|
||||
);
|
||||
|
||||
return AuthClient.withRedirectURI(clientID, hashed, salt, redirectURI);
|
||||
}
|
298
packages/auth/lib/src/auth_code_controller.dart
Normal file
298
packages/auth/lib/src/auth_code_controller.dart
Normal file
|
@ -0,0 +1,298 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// Provides [AuthCodeController] with application-specific behavior.
|
||||
@Deprecated('AuthCodeController is deprecated. See docs.')
|
||||
abstract class AuthCodeControllerDelegate {
|
||||
/// Returns an HTML representation of a login form.
|
||||
///
|
||||
/// Invoked when [AuthCodeController.getAuthorizationPage] is called in response to a GET request.
|
||||
/// Must provide HTML that will be returned to the browser for rendering. This form submission of this page
|
||||
/// should be a POST to [requestUri].
|
||||
///
|
||||
/// The form submission should include the values of [responseType], [clientID], [state], [scope]
|
||||
/// as well as user-entered username and password in `x-www-form-urlencoded` data, e.g.
|
||||
///
|
||||
/// POST https://example.com/auth/code
|
||||
/// Content-Type: application/x-www-form-urlencoded
|
||||
///
|
||||
/// response_type=code&client_id=com.conduit.app&state=o9u3jla&username=bob&password=password
|
||||
///
|
||||
///
|
||||
/// If not null, [scope] should also be included as an additional form parameter.
|
||||
Future<String?> render(
|
||||
AuthCodeController forController,
|
||||
Uri requestUri,
|
||||
String? responseType,
|
||||
String clientID,
|
||||
String? state,
|
||||
String? scope,
|
||||
);
|
||||
}
|
||||
|
||||
/// Controller for issuing OAuth 2.0 authorization codes.
|
||||
///
|
||||
/// Deprecated, use [AuthRedirectController] instead.
|
||||
///
|
||||
/// This controller provides an endpoint for the creating an OAuth 2.0 authorization code. This authorization code
|
||||
/// can be exchanged for an access token with an [AuthController]. This is known as the OAuth 2.0 'Authorization Code Grant' flow.
|
||||
///
|
||||
/// See operation methods [getAuthorizationPage] and [authorize] for more details.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// router
|
||||
/// .route("/auth/code")
|
||||
/// .link(() => new AuthCodeController(authServer));
|
||||
///
|
||||
@Deprecated('Use AuthRedirectController instead.')
|
||||
class AuthCodeController extends ResourceController {
|
||||
/// Creates a new instance of an [AuthCodeController].
|
||||
///
|
||||
/// [authServer] is the required authorization server. If [delegate] is provided, this controller will return a login page for all GET requests.
|
||||
@Deprecated('Use AuthRedirectController instead.')
|
||||
AuthCodeController(this.authServer, {this.delegate}) {
|
||||
acceptedContentTypes = [
|
||||
ContentType("application", "x-www-form-urlencoded")
|
||||
];
|
||||
}
|
||||
|
||||
/// A reference to the [AuthServer] used to grant authorization codes.
|
||||
final AuthServer authServer;
|
||||
|
||||
/// A randomly generated value the client can use to verify the origin of the redirect.
|
||||
///
|
||||
/// Clients must include this query parameter and verify that any redirects from this
|
||||
/// server have the same value for 'state' as passed in. This value is usually a randomly generated
|
||||
/// session identifier.
|
||||
@Bind.query("state")
|
||||
String? state;
|
||||
|
||||
/// Must be 'code'.
|
||||
@Bind.query("response_type")
|
||||
String? responseType;
|
||||
|
||||
/// The client ID of the authenticating client.
|
||||
///
|
||||
/// This must be a valid client ID according to [authServer].\
|
||||
@Bind.query("client_id")
|
||||
String? clientID;
|
||||
|
||||
/// Renders an HTML login form.
|
||||
final AuthCodeControllerDelegate? delegate;
|
||||
|
||||
/// Returns an HTML login form.
|
||||
///
|
||||
/// A client that wishes to authenticate with this server should direct the user
|
||||
/// to this page. The user will enter their username and password that is sent as a POST
|
||||
/// request to this same controller.
|
||||
///
|
||||
/// The 'client_id' must be a registered, valid client of this server. The client must also provide
|
||||
/// a [state] to this request and verify that the redirect contains the same value in its query string.
|
||||
@Operation.get()
|
||||
Future<Response> getAuthorizationPage({
|
||||
/// A space-delimited list of access scopes to be requested by the form submission on the returned page.
|
||||
@Bind.query("scope") String? scope,
|
||||
}) async {
|
||||
if (clientID == null) {
|
||||
return Response.badRequest();
|
||||
}
|
||||
|
||||
if (delegate == null) {
|
||||
return Response(405, {}, null);
|
||||
}
|
||||
|
||||
final renderedPage = await delegate!
|
||||
.render(this, request!.raw.uri, responseType, clientID!, state, scope);
|
||||
|
||||
return Response.ok(renderedPage)..contentType = ContentType.html;
|
||||
}
|
||||
|
||||
/// Creates a one-time use authorization code.
|
||||
///
|
||||
/// This method will respond with a redirect that contains an authorization code ('code')
|
||||
/// and the passed in 'state'. If this request fails, the redirect URL
|
||||
/// will contain an 'error' key instead of the authorization code.
|
||||
///
|
||||
/// This method is typically invoked by the login form returned from the GET to this controller.
|
||||
@Operation.post()
|
||||
Future<Response> authorize({
|
||||
/// The username of the authenticating user.
|
||||
@Bind.query("username") String? username,
|
||||
|
||||
/// The password of the authenticating user.
|
||||
@Bind.query("password") String? password,
|
||||
|
||||
/// A space-delimited list of access scopes being requested.
|
||||
@Bind.query("scope") String? scope,
|
||||
}) async {
|
||||
final client = await authServer.getClient(clientID!);
|
||||
|
||||
if (state == null) {
|
||||
return _redirectResponse(
|
||||
null,
|
||||
null,
|
||||
error: AuthServerException(AuthRequestError.invalidRequest, client),
|
||||
);
|
||||
}
|
||||
|
||||
if (responseType != "code") {
|
||||
if (client?.redirectURI == null) {
|
||||
return Response.badRequest();
|
||||
}
|
||||
|
||||
return _redirectResponse(
|
||||
null,
|
||||
state,
|
||||
error: AuthServerException(AuthRequestError.invalidRequest, client),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final scopes = scope?.split(" ").map((s) => AuthScope(s)).toList();
|
||||
|
||||
final authCode = await authServer.authenticateForCode(
|
||||
username,
|
||||
password,
|
||||
clientID!,
|
||||
requestedScopes: scopes,
|
||||
);
|
||||
return _redirectResponse(client!.redirectURI, state, code: authCode.code);
|
||||
} on FormatException {
|
||||
return _redirectResponse(
|
||||
null,
|
||||
state,
|
||||
error: AuthServerException(AuthRequestError.invalidScope, client),
|
||||
);
|
||||
} on AuthServerException catch (e) {
|
||||
return _redirectResponse(null, state, error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
APIRequestBody? documentOperationRequestBody(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
final body = super.documentOperationRequestBody(context, operation);
|
||||
if (operation!.method == "POST") {
|
||||
body!.content!["application/x-www-form-urlencoded"]!.schema!
|
||||
.properties!["password"]!.format = "password";
|
||||
body.content!["application/x-www-form-urlencoded"]!.schema!.isRequired = [
|
||||
"client_id",
|
||||
"state",
|
||||
"response_type",
|
||||
"username",
|
||||
"password"
|
||||
];
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@override
|
||||
List<APIParameter> documentOperationParameters(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
final params = super.documentOperationParameters(context, operation)!;
|
||||
params.where((p) => p.name != "scope").forEach((p) {
|
||||
p.isRequired = true;
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIResponse> documentOperationResponses(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
if (operation!.method == "GET") {
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Serves a login form.",
|
||||
APISchemaObject.string(),
|
||||
contentTypes: ["text/html"],
|
||||
)
|
||||
};
|
||||
} else if (operation.method == "POST") {
|
||||
return {
|
||||
"${HttpStatus.movedTemporarily}": APIResponse(
|
||||
"If successful, the query parameter of the redirect URI named 'code' contains authorization code. "
|
||||
"Otherwise, the query parameter 'error' is present and contains a error string.",
|
||||
headers: {
|
||||
"Location": APIHeader()
|
||||
..schema = APISchemaObject.string(format: "uri")
|
||||
},
|
||||
),
|
||||
"${HttpStatus.badRequest}": APIResponse.schema(
|
||||
"If 'client_id' is invalid, the redirect URI cannot be verified and this response is sent.",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
contentTypes: ["application/json"],
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
throw StateError("AuthCodeController documentation failed.");
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
final ops = super.documentOperations(context, route, path);
|
||||
authServer.documentedAuthorizationCodeFlow.authorizationURL =
|
||||
Uri(path: route.substring(1));
|
||||
return ops;
|
||||
}
|
||||
|
||||
static Response _redirectResponse(
|
||||
String? inputUri,
|
||||
String? clientStateOrNull, {
|
||||
String? code,
|
||||
AuthServerException? error,
|
||||
}) {
|
||||
final uriString = inputUri ?? error!.client?.redirectURI;
|
||||
if (uriString == null) {
|
||||
return Response.badRequest(body: {"error": error!.reasonString});
|
||||
}
|
||||
|
||||
final redirectURI = Uri.parse(uriString);
|
||||
final queryParameters =
|
||||
Map<String, String?>.from(redirectURI.queryParameters);
|
||||
|
||||
if (code != null) {
|
||||
queryParameters["code"] = code;
|
||||
}
|
||||
if (clientStateOrNull != null) {
|
||||
queryParameters["state"] = clientStateOrNull;
|
||||
}
|
||||
if (error != null) {
|
||||
queryParameters["error"] = error.reasonString;
|
||||
}
|
||||
|
||||
final responseURI = Uri(
|
||||
scheme: redirectURI.scheme,
|
||||
userInfo: redirectURI.userInfo,
|
||||
host: redirectURI.host,
|
||||
port: redirectURI.port,
|
||||
path: redirectURI.path,
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
return Response(
|
||||
HttpStatus.movedTemporarily,
|
||||
{
|
||||
HttpHeaders.locationHeader: responseURI.toString(),
|
||||
HttpHeaders.cacheControlHeader: "no-store",
|
||||
HttpHeaders.pragmaHeader: "no-cache"
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
226
packages/auth/lib/src/auth_controller.dart
Normal file
226
packages/auth/lib/src/auth_controller.dart
Normal file
|
@ -0,0 +1,226 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// Controller for issuing and refreshing OAuth 2.0 access tokens.
|
||||
///
|
||||
/// This controller issues and refreshes access tokens. Access tokens are issued for valid username and password (resource owner password grant)
|
||||
/// or for an authorization code (authorization code grant) from a [AuthRedirectController].
|
||||
///
|
||||
/// See operation method [grant] for more details.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// router
|
||||
/// .route("/auth/token")
|
||||
/// .link(() => new AuthController(authServer));
|
||||
///
|
||||
class AuthController extends ResourceController {
|
||||
/// Creates a new instance of an [AuthController].
|
||||
///
|
||||
/// [authServer] is the isRequired authorization server that grants tokens.
|
||||
AuthController(this.authServer) {
|
||||
acceptedContentTypes = [
|
||||
ContentType("application", "x-www-form-urlencoded")
|
||||
];
|
||||
}
|
||||
|
||||
/// A reference to the [AuthServer] this controller uses to grant tokens.
|
||||
final AuthServer authServer;
|
||||
|
||||
/// Required basic authentication Authorization header containing client ID and secret for the authenticating client.
|
||||
///
|
||||
/// Requests must contain the client ID and client secret in the authorization header,
|
||||
/// using the basic authentication scheme. If the client is a public client - i.e., no client secret -
|
||||
/// the client secret is omitted from the Authorization header.
|
||||
///
|
||||
/// Example: com.stablekernel.public is a public client. The Authorization header should be constructed
|
||||
/// as so:
|
||||
///
|
||||
/// Authorization: Basic base64("com.stablekernel.public:")
|
||||
///
|
||||
/// Notice the trailing colon indicates that the client secret is the empty string.
|
||||
@Bind.header(HttpHeaders.authorizationHeader)
|
||||
String? authHeader;
|
||||
|
||||
final AuthorizationBasicParser _parser = const AuthorizationBasicParser();
|
||||
|
||||
/// Creates or refreshes an authentication token.
|
||||
///
|
||||
/// When grant_type is 'password', there must be username and password values.
|
||||
/// When grant_type is 'refresh_token', there must be a refresh_token value.
|
||||
/// When grant_type is 'authorization_code', there must be a authorization_code value.
|
||||
///
|
||||
/// This endpoint requires client_id authentication. The Authorization header must
|
||||
/// include a valid Client ID and Secret in the Basic authorization scheme format.
|
||||
@Operation.post()
|
||||
Future<Response> grant({
|
||||
@Bind.query("username") String? username,
|
||||
@Bind.query("password") String? password,
|
||||
@Bind.query("refresh_token") String? refreshToken,
|
||||
@Bind.query("code") String? authCode,
|
||||
@Bind.query("grant_type") String? grantType,
|
||||
@Bind.query("scope") String? scope,
|
||||
}) async {
|
||||
AuthBasicCredentials basicRecord;
|
||||
try {
|
||||
basicRecord = _parser.parse(authHeader);
|
||||
} on AuthorizationParserException {
|
||||
return _responseForError(AuthRequestError.invalidClient);
|
||||
}
|
||||
|
||||
try {
|
||||
final scopes = scope?.split(" ").map((s) => AuthScope(s)).toList();
|
||||
|
||||
if (grantType == "password") {
|
||||
final token = await authServer.authenticate(
|
||||
username,
|
||||
password,
|
||||
basicRecord.username,
|
||||
basicRecord.password,
|
||||
requestedScopes: scopes,
|
||||
);
|
||||
|
||||
return AuthController.tokenResponse(token);
|
||||
} else if (grantType == "refresh_token") {
|
||||
final token = await authServer.refresh(
|
||||
refreshToken,
|
||||
basicRecord.username,
|
||||
basicRecord.password,
|
||||
requestedScopes: scopes,
|
||||
);
|
||||
|
||||
return AuthController.tokenResponse(token);
|
||||
} else if (grantType == "authorization_code") {
|
||||
if (scope != null) {
|
||||
return _responseForError(AuthRequestError.invalidRequest);
|
||||
}
|
||||
|
||||
final token = await authServer.exchange(
|
||||
authCode, basicRecord.username, basicRecord.password);
|
||||
|
||||
return AuthController.tokenResponse(token);
|
||||
} else if (grantType == null) {
|
||||
return _responseForError(AuthRequestError.invalidRequest);
|
||||
}
|
||||
} on FormatException {
|
||||
return _responseForError(AuthRequestError.invalidScope);
|
||||
} on AuthServerException catch (e) {
|
||||
return _responseForError(e.reason);
|
||||
}
|
||||
|
||||
return _responseForError(AuthRequestError.unsupportedGrantType);
|
||||
}
|
||||
|
||||
/// Transforms a [AuthToken] into a [Response] object with an RFC6749 compliant JSON token
|
||||
/// as the HTTP response body.
|
||||
static Response tokenResponse(AuthToken token) {
|
||||
return Response(
|
||||
HttpStatus.ok,
|
||||
{"Cache-Control": "no-store", "Pragma": "no-cache"},
|
||||
token.asMap(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void willSendResponse(Response response) {
|
||||
if (response.statusCode == 400) {
|
||||
// This post-processes the response in the case that duplicate parameters
|
||||
// were in the request, which violates oauth2 spec. It just adjusts the error message.
|
||||
// This could be hardened some.
|
||||
final body = response.body;
|
||||
if (body != null && body["error"] is String) {
|
||||
final errorMessage = body["error"] as String;
|
||||
if (errorMessage.startsWith("multiple values")) {
|
||||
response.body = {
|
||||
"error":
|
||||
AuthServerException.errorString(AuthRequestError.invalidRequest)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
List<APIParameter> documentOperationParameters(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
final parameters = super.documentOperationParameters(context, operation)!;
|
||||
parameters.removeWhere((p) => p.name == HttpHeaders.authorizationHeader);
|
||||
return parameters;
|
||||
}
|
||||
|
||||
@override
|
||||
APIRequestBody documentOperationRequestBody(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
final body = super.documentOperationRequestBody(context, operation)!;
|
||||
body.content!["application/x-www-form-urlencoded"]!.schema!.isRequired = [
|
||||
"grant_type"
|
||||
];
|
||||
body.content!["application/x-www-form-urlencoded"]!.schema!
|
||||
.properties!["password"]!.format = "password";
|
||||
return body;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
final operations = super.documentOperations(context, route, path);
|
||||
|
||||
operations.forEach((_, op) {
|
||||
op.security = [
|
||||
APISecurityRequirement({"oauth2-client-authentication": []})
|
||||
];
|
||||
});
|
||||
|
||||
final relativeUri = Uri(path: route.substring(1));
|
||||
authServer.documentedAuthorizationCodeFlow.tokenURL = relativeUri;
|
||||
authServer.documentedAuthorizationCodeFlow.refreshURL = relativeUri;
|
||||
|
||||
authServer.documentedPasswordFlow.tokenURL = relativeUri;
|
||||
authServer.documentedPasswordFlow.refreshURL = relativeUri;
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIResponse> documentOperationResponses(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Successfully exchanged credentials for token",
|
||||
APISchemaObject.object({
|
||||
"access_token": APISchemaObject.string(),
|
||||
"token_type": APISchemaObject.string(),
|
||||
"expires_in": APISchemaObject.integer(),
|
||||
"refresh_token": APISchemaObject.string(),
|
||||
"scope": APISchemaObject.string()
|
||||
}),
|
||||
contentTypes: ["application/json"],
|
||||
),
|
||||
"400": APIResponse.schema(
|
||||
"Invalid credentials or missing parameters.",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
contentTypes: ["application/json"],
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
Response _responseForError(AuthRequestError error) {
|
||||
return Response.badRequest(
|
||||
body: {"error": AuthServerException.errorString(error)},
|
||||
);
|
||||
}
|
||||
}
|
384
packages/auth/lib/src/auth_redirect_controller.dart
Normal file
384
packages/auth/lib/src/auth_redirect_controller.dart
Normal file
|
@ -0,0 +1,384 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// Provides [AuthRedirectController] with application-specific behavior.
|
||||
abstract class AuthRedirectControllerDelegate {
|
||||
/// Returns an HTML representation of a login form.
|
||||
///
|
||||
/// Invoked when [AuthRedirectController.getAuthorizationPage] is called in response to a GET request.
|
||||
/// Must provide HTML that will be returned to the browser for rendering. This form submission of this page
|
||||
/// should be a POST to [requestUri].
|
||||
///
|
||||
/// The form submission should include the values of [responseType], [clientID], [state], [scope]
|
||||
/// as well as user-entered username and password in `x-www-form-urlencoded` data, e.g.
|
||||
///
|
||||
/// POST https://example.com/auth/code
|
||||
/// Content-Type: application/x-www-form-urlencoded
|
||||
///
|
||||
/// response_type=code&client_id=com.conduit.app&state=o9u3jla&username=bob&password=password
|
||||
///
|
||||
///
|
||||
/// If not null, [scope] should also be included as an additional form parameter.
|
||||
Future<String?> render(
|
||||
AuthRedirectController forController,
|
||||
Uri requestUri,
|
||||
String? responseType,
|
||||
String clientID,
|
||||
String? state,
|
||||
String? scope,
|
||||
);
|
||||
}
|
||||
|
||||
/// Controller for issuing OAuth 2.0 authorization codes and tokens.
|
||||
///
|
||||
/// This controller provides an endpoint for creating an OAuth 2.0 authorization code or access token. An authorization code
|
||||
/// can be exchanged for an access token with an [AuthController]. This is known as the OAuth 2.0 'Authorization Code Grant' flow.
|
||||
/// Returning an access token is known as the OAuth 2.0 'Implicit Grant' flow.
|
||||
///
|
||||
/// See operation methods [getAuthorizationPage] and [authorize] for more details.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// router
|
||||
/// .route("/auth/code")
|
||||
/// .link(() => new AuthRedirectController(authServer));
|
||||
///
|
||||
class AuthRedirectController extends ResourceController {
|
||||
/// Creates a new instance of an [AuthRedirectController].
|
||||
///
|
||||
/// [authServer] is the required authorization server. If [delegate] is provided, this controller will return a login page for all GET requests.
|
||||
AuthRedirectController(
|
||||
this.authServer, {
|
||||
this.delegate,
|
||||
this.allowsImplicit = true,
|
||||
}) {
|
||||
acceptedContentTypes = [
|
||||
ContentType("application", "x-www-form-urlencoded")
|
||||
];
|
||||
}
|
||||
|
||||
static final Response _unsupportedResponseTypeResponse = Response.badRequest(
|
||||
body: "<h1>Error</h1><p>unsupported_response_type</p>",
|
||||
)..contentType = ContentType.html;
|
||||
|
||||
/// A reference to the [AuthServer] used to grant authorization codes and access tokens.
|
||||
late final AuthServer authServer;
|
||||
|
||||
/// When true, the controller allows for the Implicit Grant Flow
|
||||
final bool allowsImplicit;
|
||||
|
||||
/// A randomly generated value the client can use to verify the origin of the redirect.
|
||||
///
|
||||
/// Clients must include this query parameter and verify that any redirects from this
|
||||
/// server have the same value for 'state' as passed in. This value is usually a randomly generated
|
||||
/// session identifier.
|
||||
@Bind.query("state")
|
||||
String? state;
|
||||
|
||||
/// Must be 'code' or 'token'.
|
||||
@Bind.query("response_type")
|
||||
String? responseType;
|
||||
|
||||
/// The client ID of the authenticating client.
|
||||
///
|
||||
/// This must be a valid client ID according to [authServer].\
|
||||
@Bind.query("client_id")
|
||||
String? clientID;
|
||||
|
||||
/// Renders an HTML login form.
|
||||
final AuthRedirectControllerDelegate? delegate;
|
||||
|
||||
/// Returns an HTML login form.
|
||||
///
|
||||
/// A client that wishes to authenticate with this server should direct the user
|
||||
/// to this page. The user will enter their username and password that is sent as a POST
|
||||
/// request to this same controller.
|
||||
///
|
||||
/// The 'client_id' must be a registered, valid client of this server. The client must also provide
|
||||
/// a [state] to this request and verify that the redirect contains the same value in its query string.
|
||||
@Operation.get()
|
||||
Future<Response> getAuthorizationPage({
|
||||
/// A space-delimited list of access scopes to be requested by the form submission on the returned page.
|
||||
@Bind.query("scope") String? scope,
|
||||
}) async {
|
||||
if (delegate == null) {
|
||||
return Response(405, {}, null);
|
||||
}
|
||||
|
||||
if (responseType != "code" && responseType != "token") {
|
||||
return _unsupportedResponseTypeResponse;
|
||||
}
|
||||
|
||||
if (responseType == "token" && !allowsImplicit) {
|
||||
return _unsupportedResponseTypeResponse;
|
||||
}
|
||||
|
||||
final renderedPage = await delegate!
|
||||
.render(this, request!.raw.uri, responseType, clientID!, state, scope);
|
||||
if (renderedPage == null) {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
return Response.ok(renderedPage)..contentType = ContentType.html;
|
||||
}
|
||||
|
||||
/// Creates a one-time use authorization code or an access token.
|
||||
///
|
||||
/// This method will respond with a redirect that either contains an authorization code ('code')
|
||||
/// or an access token ('token') along with the passed in 'state'. If this request fails,
|
||||
/// the redirect URL will contain an 'error' instead of the authorization code or access token.
|
||||
///
|
||||
/// This method is typically invoked by the login form returned from the GET to this controller.
|
||||
@Operation.post()
|
||||
Future<Response> authorize({
|
||||
/// The username of the authenticating user.
|
||||
@Bind.query("username") String? username,
|
||||
|
||||
/// The password of the authenticating user.
|
||||
@Bind.query("password") String? password,
|
||||
|
||||
/// A space-delimited list of access scopes being requested.
|
||||
@Bind.query("scope") String? scope,
|
||||
}) async {
|
||||
if (clientID == null) {
|
||||
return Response.badRequest();
|
||||
}
|
||||
|
||||
final client = await authServer.getClient(clientID!);
|
||||
|
||||
if (client?.redirectURI == null) {
|
||||
return Response.badRequest();
|
||||
}
|
||||
|
||||
if (responseType == "token" && !allowsImplicit) {
|
||||
return _unsupportedResponseTypeResponse;
|
||||
}
|
||||
|
||||
if (state == null) {
|
||||
return _redirectResponse(
|
||||
null,
|
||||
null,
|
||||
error: AuthServerException(AuthRequestError.invalidRequest, client),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final scopes = scope?.split(" ").map((s) => AuthScope(s)).toList();
|
||||
|
||||
if (responseType == "code") {
|
||||
if (client!.hashedSecret == null) {
|
||||
return _redirectResponse(
|
||||
null,
|
||||
state,
|
||||
error: AuthServerException(
|
||||
AuthRequestError.unauthorizedClient,
|
||||
client,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final authCode = await authServer.authenticateForCode(
|
||||
username,
|
||||
password,
|
||||
clientID!,
|
||||
requestedScopes: scopes,
|
||||
);
|
||||
return _redirectResponse(
|
||||
client.redirectURI,
|
||||
state,
|
||||
code: authCode.code,
|
||||
);
|
||||
} else if (responseType == "token") {
|
||||
final token = await authServer.authenticate(
|
||||
username,
|
||||
password,
|
||||
clientID!,
|
||||
null,
|
||||
requestedScopes: scopes,
|
||||
);
|
||||
return _redirectResponse(client!.redirectURI, state, token: token);
|
||||
} else {
|
||||
return _redirectResponse(
|
||||
null,
|
||||
state,
|
||||
error: AuthServerException(AuthRequestError.invalidRequest, client),
|
||||
);
|
||||
}
|
||||
} on FormatException {
|
||||
return _redirectResponse(
|
||||
null,
|
||||
state,
|
||||
error: AuthServerException(AuthRequestError.invalidScope, client),
|
||||
);
|
||||
} on AuthServerException catch (e) {
|
||||
if (responseType == "token" &&
|
||||
e.reason == AuthRequestError.invalidGrant) {
|
||||
return _redirectResponse(
|
||||
null,
|
||||
state,
|
||||
error: AuthServerException(AuthRequestError.accessDenied, client),
|
||||
);
|
||||
}
|
||||
|
||||
return _redirectResponse(null, state, error: e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
APIRequestBody? documentOperationRequestBody(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
final body = super.documentOperationRequestBody(context, operation);
|
||||
if (operation!.method == "POST") {
|
||||
body!.content!["application/x-www-form-urlencoded"]!.schema!
|
||||
.properties!["password"]!.format = "password";
|
||||
body.content!["application/x-www-form-urlencoded"]!.schema!.isRequired = [
|
||||
"client_id",
|
||||
"state",
|
||||
"response_type",
|
||||
"username",
|
||||
"password"
|
||||
];
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
@override
|
||||
List<APIParameter> documentOperationParameters(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
final params = super.documentOperationParameters(context, operation)!;
|
||||
params.where((p) => p.name != "scope").forEach((p) {
|
||||
p.isRequired = true;
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIResponse> documentOperationResponses(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
if (operation!.method == "GET") {
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Serves a login form.",
|
||||
APISchemaObject.string(),
|
||||
contentTypes: ["text/html"],
|
||||
)
|
||||
};
|
||||
} else if (operation.method == "POST") {
|
||||
return {
|
||||
"${HttpStatus.movedTemporarily}": APIResponse(
|
||||
"If successful, in the case of a 'response type' of 'code', the query "
|
||||
"parameter of the redirect URI named 'code' contains authorization code. "
|
||||
"Otherwise, the query parameter 'error' is present and contains a error string. "
|
||||
"In the case of a 'response type' of 'token', the redirect URI's fragment "
|
||||
"contains an access token. Otherwise, the fragment contains an error code.",
|
||||
headers: {
|
||||
"Location": APIHeader()
|
||||
..schema = APISchemaObject.string(format: "uri")
|
||||
},
|
||||
),
|
||||
"${HttpStatus.badRequest}": APIResponse.schema(
|
||||
"If 'client_id' is invalid, the redirect URI cannot be verified and this response is sent.",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
contentTypes: ["application/json"],
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
throw StateError("AuthRedirectController documentation failed.");
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
final ops = super.documentOperations(context, route, path);
|
||||
final uri = Uri(path: route.substring(1));
|
||||
authServer.documentedAuthorizationCodeFlow.authorizationURL = uri;
|
||||
authServer.documentedImplicitFlow.authorizationURL = uri;
|
||||
return ops;
|
||||
}
|
||||
|
||||
Response _redirectResponse(
|
||||
String? inputUri,
|
||||
String? clientStateOrNull, {
|
||||
String? code,
|
||||
AuthToken? token,
|
||||
AuthServerException? error,
|
||||
}) {
|
||||
final uriString = inputUri ?? error!.client?.redirectURI;
|
||||
if (uriString == null) {
|
||||
return Response.badRequest(body: {"error": error!.reasonString});
|
||||
}
|
||||
|
||||
Uri redirectURI;
|
||||
|
||||
try {
|
||||
redirectURI = Uri.parse(uriString);
|
||||
} catch (error) {
|
||||
return Response.badRequest();
|
||||
}
|
||||
|
||||
final queryParameters =
|
||||
Map<String, String>.from(redirectURI.queryParameters);
|
||||
String? fragment;
|
||||
|
||||
if (responseType == "code") {
|
||||
if (code != null) {
|
||||
queryParameters["code"] = code;
|
||||
}
|
||||
if (clientStateOrNull != null) {
|
||||
queryParameters["state"] = clientStateOrNull;
|
||||
}
|
||||
if (error != null) {
|
||||
queryParameters["error"] = error.reasonString;
|
||||
}
|
||||
} else if (responseType == "token") {
|
||||
final params = token?.asMap() ?? {};
|
||||
|
||||
if (clientStateOrNull != null) {
|
||||
params["state"] = clientStateOrNull;
|
||||
}
|
||||
if (error != null) {
|
||||
params["error"] = error.reasonString;
|
||||
}
|
||||
|
||||
fragment = params.keys
|
||||
.map((key) => "$key=${Uri.encodeComponent(params[key].toString())}")
|
||||
.join("&");
|
||||
} else {
|
||||
return _unsupportedResponseTypeResponse;
|
||||
}
|
||||
|
||||
final responseURI = Uri(
|
||||
scheme: redirectURI.scheme,
|
||||
userInfo: redirectURI.userInfo,
|
||||
host: redirectURI.host,
|
||||
port: redirectURI.port,
|
||||
path: redirectURI.path,
|
||||
queryParameters: queryParameters,
|
||||
fragment: fragment,
|
||||
);
|
||||
return Response(
|
||||
HttpStatus.movedTemporarily,
|
||||
{
|
||||
HttpHeaders.locationHeader: responseURI.toString(),
|
||||
HttpHeaders.cacheControlHeader: "no-store",
|
||||
HttpHeaders.pragmaHeader: "no-cache"
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
}
|
111
packages/auth/lib/src/authorization_parser.dart
Normal file
111
packages/auth/lib/src/authorization_parser.dart
Normal file
|
@ -0,0 +1,111 @@
|
|||
import 'dart:convert';
|
||||
|
||||
abstract class AuthorizationParser<T> {
|
||||
const AuthorizationParser();
|
||||
|
||||
T parse(String authorizationHeader);
|
||||
}
|
||||
|
||||
/// Parses a Bearer token from an Authorization header.
|
||||
class AuthorizationBearerParser extends AuthorizationParser<String?> {
|
||||
const AuthorizationBearerParser();
|
||||
|
||||
/// Parses a Bearer token from [authorizationHeader]. If the header is malformed or doesn't exist,
|
||||
/// throws an [AuthorizationParserException]. Otherwise, returns the [String] representation of the bearer token.
|
||||
///
|
||||
/// For example, if the input to this method is "Bearer token" it would return 'token'.
|
||||
///
|
||||
/// If [authorizationHeader] is malformed or null, throws an [AuthorizationParserException].
|
||||
@override
|
||||
String? parse(String authorizationHeader) {
|
||||
if (authorizationHeader.isEmpty) {
|
||||
throw AuthorizationParserException(
|
||||
AuthorizationParserExceptionReason.missing,
|
||||
);
|
||||
}
|
||||
|
||||
final matcher = RegExp("Bearer (.+)");
|
||||
final match = matcher.firstMatch(authorizationHeader);
|
||||
if (match == null) {
|
||||
throw AuthorizationParserException(
|
||||
AuthorizationParserExceptionReason.malformed,
|
||||
);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
|
||||
/// A structure to hold Basic authorization credentials.
|
||||
///
|
||||
/// See [AuthorizationBasicParser] for getting instances of this type.
|
||||
class AuthBasicCredentials {
|
||||
/// The username of a Basic Authorization header.
|
||||
late final String username;
|
||||
|
||||
/// The password of a Basic Authorization header.
|
||||
late final String password;
|
||||
|
||||
@override
|
||||
String toString() => "$username:$password";
|
||||
}
|
||||
|
||||
/// Parses a Basic Authorization header.
|
||||
class AuthorizationBasicParser
|
||||
extends AuthorizationParser<AuthBasicCredentials> {
|
||||
const AuthorizationBasicParser();
|
||||
|
||||
/// Returns a [AuthBasicCredentials] containing the username and password
|
||||
/// base64 encoded in [authorizationHeader]. For example, if the input to this method
|
||||
/// was 'Basic base64String' it would decode the base64String
|
||||
/// and return the username and password by splitting that decoded string around the character ':'.
|
||||
///
|
||||
/// If [authorizationHeader] is malformed or null, throws an [AuthorizationParserException].
|
||||
@override
|
||||
AuthBasicCredentials parse(String? authorizationHeader) {
|
||||
if (authorizationHeader == null) {
|
||||
throw AuthorizationParserException(
|
||||
AuthorizationParserExceptionReason.missing,
|
||||
);
|
||||
}
|
||||
|
||||
final matcher = RegExp("Basic (.+)");
|
||||
final match = matcher.firstMatch(authorizationHeader);
|
||||
if (match == null) {
|
||||
throw AuthorizationParserException(
|
||||
AuthorizationParserExceptionReason.malformed,
|
||||
);
|
||||
}
|
||||
|
||||
final base64String = match[1]!;
|
||||
String decodedCredentials;
|
||||
try {
|
||||
decodedCredentials =
|
||||
String.fromCharCodes(const Base64Decoder().convert(base64String));
|
||||
} catch (e) {
|
||||
throw AuthorizationParserException(
|
||||
AuthorizationParserExceptionReason.malformed,
|
||||
);
|
||||
}
|
||||
|
||||
final splitCredentials = decodedCredentials.split(":");
|
||||
if (splitCredentials.length != 2) {
|
||||
throw AuthorizationParserException(
|
||||
AuthorizationParserExceptionReason.malformed,
|
||||
);
|
||||
}
|
||||
|
||||
return AuthBasicCredentials()
|
||||
..username = splitCredentials.first
|
||||
..password = splitCredentials.last;
|
||||
}
|
||||
}
|
||||
|
||||
/// The reason either [AuthorizationBearerParser] or [AuthorizationBasicParser] failed.
|
||||
enum AuthorizationParserExceptionReason { missing, malformed }
|
||||
|
||||
/// An exception indicating why Authorization parsing failed.
|
||||
class AuthorizationParserException implements Exception {
|
||||
AuthorizationParserException(this.reason);
|
||||
|
||||
AuthorizationParserExceptionReason reason;
|
||||
}
|
668
packages/auth/lib/src/authorization_server.dart
Normal file
668
packages/auth/lib/src/authorization_server.dart
Normal file
|
@ -0,0 +1,668 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// A OAuth 2.0 authorization server.
|
||||
///
|
||||
/// An [AuthServer] is an implementation of an OAuth 2.0 authorization server. An authorization server
|
||||
/// issues, refreshes and revokes access tokens. It also verifies previously issued tokens, as
|
||||
/// well as client and resource owner credentials.
|
||||
///
|
||||
/// [AuthServer]s are typically used in conjunction with [AuthController] and [AuthRedirectController].
|
||||
/// These controllers provide HTTP interfaces to the [AuthServer] for issuing and refreshing tokens.
|
||||
/// Likewise, [Authorizer]s verify these issued tokens to protect endpoint controllers.
|
||||
///
|
||||
/// [AuthServer]s can be customized through their [delegate]. This required property manages persistent storage of authorization
|
||||
/// objects among other tasks. There are security considerations for [AuthServerDelegate] implementations; prefer to use a tested
|
||||
/// implementation like `ManagedAuthDelegate` from `package:conduit_core/managed_auth.dart`.
|
||||
///
|
||||
/// Usage example with `ManagedAuthDelegate`:
|
||||
///
|
||||
/// import 'package:conduit_core/conduit_core.dart';
|
||||
/// import 'package:conduit_core/managed_auth.dart';
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User, ManagedAuthResourceOwner {}
|
||||
/// class _User extends ManagedAuthenticatable {}
|
||||
///
|
||||
/// class Channel extends ApplicationChannel {
|
||||
/// ManagedContext context;
|
||||
/// AuthServer authServer;
|
||||
///
|
||||
/// @override
|
||||
/// Future prepare() async {
|
||||
/// context = createContext();
|
||||
///
|
||||
/// final delegate = new ManagedAuthStorage<User>(context);
|
||||
/// authServer = new AuthServer(delegate);
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Controller get entryPoint {
|
||||
/// final router = new Router();
|
||||
/// router
|
||||
/// .route("/protected")
|
||||
/// .link(() =>new Authorizer(authServer))
|
||||
/// .link(() => new ProtectedResourceController());
|
||||
///
|
||||
/// router
|
||||
/// .route("/auth/token")
|
||||
/// .link(() => new AuthController(authServer));
|
||||
///
|
||||
/// return router;
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
class AuthServer implements AuthValidator, APIComponentDocumenter {
|
||||
/// Creates a new instance of an [AuthServer] with a [delegate].
|
||||
///
|
||||
/// [hashFunction] defaults to [sha256].
|
||||
AuthServer(
|
||||
this.delegate, {
|
||||
this.hashRounds = 1000,
|
||||
this.hashLength = 32,
|
||||
this.hashFunction = sha256,
|
||||
});
|
||||
|
||||
/// The object responsible for carrying out the storage mechanisms of this instance.
|
||||
///
|
||||
/// This instance is responsible for storing, fetching and deleting instances of
|
||||
/// [AuthToken], [AuthCode] and [AuthClient] by implementing the [AuthServerDelegate] interface.
|
||||
///
|
||||
/// It is preferable to use the implementation of [AuthServerDelegate] from 'package:conduit_core/managed_auth.dart'. See
|
||||
/// [AuthServer] for more details.
|
||||
final AuthServerDelegate delegate;
|
||||
|
||||
/// The number of hashing rounds performed by this instance when validating a password.
|
||||
final int hashRounds;
|
||||
|
||||
/// The resulting key length of a password hash when generated by this instance.
|
||||
final int hashLength;
|
||||
|
||||
/// The [Hash] function used by the PBKDF2 algorithm to generate password hashes by this instance.
|
||||
final Hash hashFunction;
|
||||
|
||||
/// Used during OpenAPI documentation.
|
||||
final APISecuritySchemeOAuth2Flow documentedAuthorizationCodeFlow =
|
||||
APISecuritySchemeOAuth2Flow.empty()..scopes = {};
|
||||
|
||||
/// Used during OpenAPI documentation.
|
||||
final APISecuritySchemeOAuth2Flow documentedPasswordFlow =
|
||||
APISecuritySchemeOAuth2Flow.empty()..scopes = {};
|
||||
|
||||
/// Used during OpenAPI documentation.
|
||||
final APISecuritySchemeOAuth2Flow documentedImplicitFlow =
|
||||
APISecuritySchemeOAuth2Flow.empty()..scopes = {};
|
||||
|
||||
static const String tokenTypeBearer = "bearer";
|
||||
|
||||
/// Hashes a [password] with [salt] using PBKDF2 algorithm.
|
||||
///
|
||||
/// See [hashRounds], [hashLength] and [hashFunction] for more details. This method
|
||||
/// invoke [auth.generatePasswordHash] with the above inputs.
|
||||
String hashPassword(String password, String salt) {
|
||||
return generatePasswordHash(
|
||||
password,
|
||||
salt,
|
||||
hashRounds: hashRounds,
|
||||
hashLength: hashLength,
|
||||
hashFunction: hashFunction,
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds an OAuth2 client.
|
||||
///
|
||||
/// [delegate] will store this client for future use.
|
||||
Future addClient(AuthClient client) async {
|
||||
if (client.id.isEmpty) {
|
||||
throw ArgumentError(
|
||||
"A client must have an id.",
|
||||
);
|
||||
}
|
||||
|
||||
if (client.redirectURI != null && client.hashedSecret == null) {
|
||||
throw ArgumentError(
|
||||
"A client with a redirectURI must have a client secret.",
|
||||
);
|
||||
}
|
||||
|
||||
return delegate.addClient(this, client);
|
||||
}
|
||||
|
||||
/// Returns a [AuthClient] record for its [clientID].
|
||||
///
|
||||
/// Returns null if none exists.
|
||||
Future<AuthClient?> getClient(String clientID) async {
|
||||
return delegate.getClient(this, clientID);
|
||||
}
|
||||
|
||||
/// Revokes a [AuthClient] record.
|
||||
///
|
||||
/// Removes cached occurrences of [AuthClient] for [clientID].
|
||||
/// Asks [delegate] to remove an [AuthClient] by its ID via [AuthServerDelegate.removeClient].
|
||||
Future removeClient(String clientID) async {
|
||||
if (clientID.isEmpty) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
return delegate.removeClient(this, clientID);
|
||||
}
|
||||
|
||||
/// Revokes access for an [ResourceOwner].
|
||||
///
|
||||
/// All authorization codes and tokens for the [ResourceOwner] identified by [identifier]
|
||||
/// will be revoked.
|
||||
Future revokeAllGrantsForResourceOwner(int? identifier) async {
|
||||
if (identifier == null) {
|
||||
throw ArgumentError.notNull("identifier");
|
||||
}
|
||||
|
||||
await delegate.removeTokens(this, identifier);
|
||||
}
|
||||
|
||||
/// Authenticates a username and password of an [ResourceOwner] and returns an [AuthToken] upon success.
|
||||
///
|
||||
/// This method works with this instance's [delegate] to generate and store a new token if all credentials are correct.
|
||||
/// If credentials are not correct, it will throw the appropriate [AuthRequestError].
|
||||
///
|
||||
/// After [expiration], this token will no longer be valid.
|
||||
Future<AuthToken> authenticate(
|
||||
String? username,
|
||||
String? password,
|
||||
String clientID,
|
||||
String? clientSecret, {
|
||||
Duration expiration = const Duration(hours: 24),
|
||||
List<AuthScope>? requestedScopes,
|
||||
}) async {
|
||||
if (clientID.isEmpty) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
final client = await getClient(clientID);
|
||||
if (client == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
if (username == null || password == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidRequest, client);
|
||||
}
|
||||
|
||||
if (client.isPublic) {
|
||||
if (!(clientSecret == null || clientSecret == "")) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||
}
|
||||
} else {
|
||||
if (clientSecret == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||
}
|
||||
|
||||
if (client.hashedSecret != hashPassword(clientSecret, client.salt!)) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||
}
|
||||
}
|
||||
|
||||
final authenticatable = await delegate.getResourceOwner(this, username);
|
||||
if (authenticatable == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||
}
|
||||
|
||||
final dbSalt = authenticatable.salt!;
|
||||
final dbPassword = authenticatable.hashedPassword;
|
||||
final hash = hashPassword(password, dbSalt);
|
||||
if (hash != dbPassword) {
|
||||
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||
}
|
||||
|
||||
final validScopes =
|
||||
_validatedScopes(client, authenticatable, requestedScopes);
|
||||
final token = _generateToken(
|
||||
authenticatable.id,
|
||||
client.id,
|
||||
expiration.inSeconds,
|
||||
allowRefresh: !client.isPublic,
|
||||
scopes: validScopes,
|
||||
);
|
||||
await delegate.addToken(this, token);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Returns a [Authorization] for [accessToken].
|
||||
///
|
||||
/// This method obtains an [AuthToken] for [accessToken] from [delegate] and then verifies that the token is valid.
|
||||
/// If the token is valid, an [Authorization] object is returned. Otherwise, an [AuthServerException] is thrown.
|
||||
Future<Authorization> verify(
|
||||
String? accessToken, {
|
||||
List<AuthScope>? scopesRequired,
|
||||
}) async {
|
||||
if (accessToken == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidRequest, null);
|
||||
}
|
||||
|
||||
final t = await delegate.getToken(this, byAccessToken: accessToken);
|
||||
if (t == null || t.isExpired) {
|
||||
throw AuthServerException(
|
||||
AuthRequestError.invalidGrant,
|
||||
AuthClient(t?.clientID ?? '', null, null),
|
||||
);
|
||||
}
|
||||
|
||||
if (scopesRequired != null) {
|
||||
if (!AuthScope.verify(scopesRequired, t.scopes)) {
|
||||
throw AuthServerException(
|
||||
AuthRequestError.invalidScope,
|
||||
AuthClient(t.clientID, null, null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Authorization(
|
||||
t.clientID,
|
||||
t.resourceOwnerIdentifier,
|
||||
this,
|
||||
scopes: t.scopes,
|
||||
);
|
||||
}
|
||||
|
||||
/// Refreshes a valid [AuthToken] instance.
|
||||
///
|
||||
/// This method will refresh a [AuthToken] given the [AuthToken]'s [refreshToken] for a given client ID.
|
||||
/// This method coordinates with this instance's [delegate] to update the old token with a new access token and issue/expiration dates if successful.
|
||||
/// If not successful, it will throw an [AuthRequestError].
|
||||
Future<AuthToken> refresh(
|
||||
String? refreshToken,
|
||||
String clientID,
|
||||
String? clientSecret, {
|
||||
List<AuthScope>? requestedScopes,
|
||||
}) async {
|
||||
if (clientID.isEmpty) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
final client = await getClient(clientID);
|
||||
if (client == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
if (refreshToken == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidRequest, client);
|
||||
}
|
||||
|
||||
final t = await delegate.getToken(this, byRefreshToken: refreshToken);
|
||||
if (t == null || t.clientID != clientID) {
|
||||
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||
}
|
||||
|
||||
if (clientSecret == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||
}
|
||||
|
||||
if (client.hashedSecret != hashPassword(clientSecret, client.salt!)) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||
}
|
||||
|
||||
var updatedScopes = t.scopes;
|
||||
if ((requestedScopes?.length ?? 0) != 0) {
|
||||
// If we do specify scope
|
||||
for (final incomingScope in requestedScopes!) {
|
||||
final hasExistingScopeOrSuperset = t.scopes!.any(
|
||||
(existingScope) => incomingScope.isSubsetOrEqualTo(existingScope),
|
||||
);
|
||||
|
||||
if (!hasExistingScopeOrSuperset) {
|
||||
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||
}
|
||||
|
||||
if (!client.allowsScope(incomingScope)) {
|
||||
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||
}
|
||||
}
|
||||
|
||||
updatedScopes = requestedScopes;
|
||||
} else if (client.supportsScopes) {
|
||||
// Ensure we still have access to same scopes if we didn't specify any
|
||||
for (final incomingScope in t.scopes!) {
|
||||
if (!client.allowsScope(incomingScope)) {
|
||||
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final diff = t.expirationDate!.difference(t.issueDate!);
|
||||
final now = DateTime.now().toUtc();
|
||||
final newToken = AuthToken()
|
||||
..accessToken = randomStringOfLength(32)
|
||||
..issueDate = now
|
||||
..expirationDate = now.add(Duration(seconds: diff.inSeconds)).toUtc()
|
||||
..refreshToken = t.refreshToken
|
||||
..type = t.type
|
||||
..scopes = updatedScopes
|
||||
..resourceOwnerIdentifier = t.resourceOwnerIdentifier
|
||||
..clientID = t.clientID;
|
||||
|
||||
await delegate.updateToken(
|
||||
this,
|
||||
t.accessToken,
|
||||
newToken.accessToken,
|
||||
newToken.issueDate,
|
||||
newToken.expirationDate,
|
||||
);
|
||||
|
||||
return newToken;
|
||||
}
|
||||
|
||||
/// Creates a one-time use authorization code for a given client ID and user credentials.
|
||||
///
|
||||
/// This methods works with this instance's [delegate] to generate and store the authorization code
|
||||
/// if the credentials are correct. If they are not correct, it will throw the
|
||||
/// appropriate [AuthRequestError].
|
||||
Future<AuthCode> authenticateForCode(
|
||||
String? username,
|
||||
String? password,
|
||||
String clientID, {
|
||||
int expirationInSeconds = 600,
|
||||
List<AuthScope>? requestedScopes,
|
||||
}) async {
|
||||
if (clientID.isEmpty) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
final client = await getClient(clientID);
|
||||
if (client == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
if (username == null || password == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidRequest, client);
|
||||
}
|
||||
|
||||
if (client.redirectURI == null) {
|
||||
throw AuthServerException(AuthRequestError.unauthorizedClient, client);
|
||||
}
|
||||
|
||||
final authenticatable = await delegate.getResourceOwner(this, username);
|
||||
if (authenticatable == null) {
|
||||
throw AuthServerException(AuthRequestError.accessDenied, client);
|
||||
}
|
||||
|
||||
final dbSalt = authenticatable.salt;
|
||||
final dbPassword = authenticatable.hashedPassword;
|
||||
if (hashPassword(password, dbSalt!) != dbPassword) {
|
||||
throw AuthServerException(AuthRequestError.accessDenied, client);
|
||||
}
|
||||
|
||||
final validScopes =
|
||||
_validatedScopes(client, authenticatable, requestedScopes);
|
||||
final authCode = _generateAuthCode(
|
||||
authenticatable.id,
|
||||
client,
|
||||
expirationInSeconds,
|
||||
scopes: validScopes,
|
||||
);
|
||||
await delegate.addCode(this, authCode);
|
||||
return authCode;
|
||||
}
|
||||
|
||||
/// Exchanges a valid authorization code for an [AuthToken].
|
||||
///
|
||||
/// If the authorization code has not expired, has not been used, matches the client ID,
|
||||
/// and the client secret is correct, it will return a valid [AuthToken]. Otherwise,
|
||||
/// it will throw an appropriate [AuthRequestError].
|
||||
Future<AuthToken> exchange(
|
||||
String? authCodeString,
|
||||
String clientID,
|
||||
String? clientSecret, {
|
||||
int expirationInSeconds = 3600,
|
||||
}) async {
|
||||
if (clientID.isEmpty) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
final client = await getClient(clientID);
|
||||
if (client == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
if (authCodeString == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidRequest, null);
|
||||
}
|
||||
|
||||
if (clientSecret == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||
}
|
||||
|
||||
if (client.hashedSecret != hashPassword(clientSecret, client.salt!)) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||
}
|
||||
|
||||
final authCode = await delegate.getCode(this, authCodeString);
|
||||
if (authCode == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||
}
|
||||
|
||||
// check if valid still
|
||||
if (authCode.isExpired) {
|
||||
await delegate.removeCode(this, authCode.code);
|
||||
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||
}
|
||||
|
||||
// check that client ids match
|
||||
if (authCode.clientID != client.id) {
|
||||
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||
}
|
||||
|
||||
// check to see if has already been used
|
||||
if (authCode.hasBeenExchanged!) {
|
||||
await delegate.removeToken(this, authCode);
|
||||
|
||||
throw AuthServerException(AuthRequestError.invalidGrant, client);
|
||||
}
|
||||
final token = _generateToken(
|
||||
authCode.resourceOwnerIdentifier,
|
||||
client.id,
|
||||
expirationInSeconds,
|
||||
scopes: authCode.requestedScopes,
|
||||
);
|
||||
await delegate.addToken(this, token, issuedFrom: authCode);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
//////
|
||||
// APIDocumentable overrides
|
||||
//////
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
final basic = APISecurityScheme.http("basic")
|
||||
..description =
|
||||
"This endpoint requires an OAuth2 Client ID and Secret as the Basic Authentication username and password. "
|
||||
"If the client ID does not have a secret (public client), the password is the empty string (retain the separating colon, e.g. 'com.conduit.app:').";
|
||||
context.securitySchemes.register("oauth2-client-authentication", basic);
|
||||
|
||||
final oauth2 = APISecurityScheme.oauth2({
|
||||
"authorizationCode": documentedAuthorizationCodeFlow,
|
||||
"password": documentedPasswordFlow
|
||||
})
|
||||
..description = "Standard OAuth 2.0";
|
||||
|
||||
context.securitySchemes.register("oauth2", oauth2);
|
||||
|
||||
context.defer(() {
|
||||
if (documentedAuthorizationCodeFlow.authorizationURL == null) {
|
||||
oauth2.flows!.remove("authorizationCode");
|
||||
}
|
||||
|
||||
if (documentedAuthorizationCodeFlow.tokenURL == null) {
|
||||
oauth2.flows!.remove("authorizationCode");
|
||||
}
|
||||
|
||||
if (documentedPasswordFlow.tokenURL == null) {
|
||||
oauth2.flows!.remove("password");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/////
|
||||
// AuthValidator overrides
|
||||
/////
|
||||
@override
|
||||
List<APISecurityRequirement> documentRequirementsForAuthorizer(
|
||||
APIDocumentContext context,
|
||||
Authorizer authorizer, {
|
||||
List<AuthScope>? scopes,
|
||||
}) {
|
||||
if (authorizer.parser is AuthorizationBasicParser) {
|
||||
return [
|
||||
APISecurityRequirement({"oauth2-client-authentication": []})
|
||||
];
|
||||
} else if (authorizer.parser is AuthorizationBearerParser) {
|
||||
return [
|
||||
APISecurityRequirement(
|
||||
{"oauth2": scopes?.map((s) => s.toString()).toList() ?? []},
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<Authorization> validate<T>(
|
||||
AuthorizationParser<T> parser,
|
||||
T authorizationData, {
|
||||
List<AuthScope>? requiredScope,
|
||||
}) {
|
||||
if (parser is AuthorizationBasicParser) {
|
||||
final credentials = authorizationData as AuthBasicCredentials;
|
||||
return _validateClientCredentials(credentials);
|
||||
} else if (parser is AuthorizationBearerParser) {
|
||||
return verify(authorizationData as String, scopesRequired: requiredScope);
|
||||
}
|
||||
|
||||
throw ArgumentError(
|
||||
"Invalid 'parser' for 'AuthServer.validate'. Use 'AuthorizationBasicParser' or 'AuthorizationBearerHeader'.",
|
||||
);
|
||||
}
|
||||
|
||||
Future<Authorization> _validateClientCredentials(
|
||||
AuthBasicCredentials credentials,
|
||||
) async {
|
||||
final username = credentials.username;
|
||||
final password = credentials.password;
|
||||
|
||||
final client = await getClient(username);
|
||||
|
||||
if (client == null) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, null);
|
||||
}
|
||||
|
||||
if (client.hashedSecret == null) {
|
||||
if (password == "") {
|
||||
return Authorization(client.id, null, this, credentials: credentials);
|
||||
}
|
||||
|
||||
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||
}
|
||||
|
||||
if (client.hashedSecret != hashPassword(password, client.salt!)) {
|
||||
throw AuthServerException(AuthRequestError.invalidClient, client);
|
||||
}
|
||||
|
||||
return Authorization(client.id, null, this, credentials: credentials);
|
||||
}
|
||||
|
||||
List<AuthScope>? _validatedScopes(
|
||||
AuthClient client,
|
||||
ResourceOwner authenticatable,
|
||||
List<AuthScope>? requestedScopes,
|
||||
) {
|
||||
List<AuthScope>? validScopes;
|
||||
if (client.supportsScopes) {
|
||||
if ((requestedScopes?.length ?? 0) == 0) {
|
||||
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||
}
|
||||
|
||||
validScopes = requestedScopes!
|
||||
.where((incomingScope) => client.allowsScope(incomingScope))
|
||||
.toList();
|
||||
|
||||
if (validScopes.isEmpty) {
|
||||
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||
}
|
||||
|
||||
final validScopesForAuthenticatable =
|
||||
delegate.getAllowedScopes(authenticatable);
|
||||
if (!identical(validScopesForAuthenticatable, AuthScope.any)) {
|
||||
validScopes.retainWhere(
|
||||
(clientAllowedScope) => validScopesForAuthenticatable!.any(
|
||||
(userScope) => clientAllowedScope.isSubsetOrEqualTo(userScope),
|
||||
),
|
||||
);
|
||||
|
||||
if (validScopes.isEmpty) {
|
||||
throw AuthServerException(AuthRequestError.invalidScope, client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return validScopes;
|
||||
}
|
||||
|
||||
AuthToken _generateToken(
|
||||
int? ownerID,
|
||||
String clientID,
|
||||
int expirationInSeconds, {
|
||||
bool allowRefresh = true,
|
||||
List<AuthScope>? scopes,
|
||||
}) {
|
||||
final now = DateTime.now().toUtc();
|
||||
final token = AuthToken()
|
||||
..accessToken = randomStringOfLength(32)
|
||||
..issueDate = now
|
||||
..expirationDate = now.add(Duration(seconds: expirationInSeconds))
|
||||
..type = tokenTypeBearer
|
||||
..resourceOwnerIdentifier = ownerID
|
||||
..scopes = scopes
|
||||
..clientID = clientID;
|
||||
|
||||
if (allowRefresh) {
|
||||
token.refreshToken = randomStringOfLength(32);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
AuthCode _generateAuthCode(
|
||||
int? ownerID,
|
||||
AuthClient client,
|
||||
int expirationInSeconds, {
|
||||
List<AuthScope>? scopes,
|
||||
}) {
|
||||
final now = DateTime.now().toUtc();
|
||||
return AuthCode()
|
||||
..code = randomStringOfLength(32)
|
||||
..clientID = client.id
|
||||
..resourceOwnerIdentifier = ownerID
|
||||
..issueDate = now
|
||||
..requestedScopes = scopes
|
||||
..expirationDate = now.add(Duration(seconds: expirationInSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
String randomStringOfLength(int length) {
|
||||
const possibleCharacters =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
final buff = StringBuffer();
|
||||
|
||||
final r = Random.secure();
|
||||
for (int i = 0; i < length; i++) {
|
||||
buff.write(
|
||||
possibleCharacters[r.nextInt(1000) % possibleCharacters.length],
|
||||
);
|
||||
}
|
||||
|
||||
return buff.toString();
|
||||
}
|
228
packages/auth/lib/src/authorizer.dart
Normal file
228
packages/auth/lib/src/authorizer.dart
Normal file
|
@ -0,0 +1,228 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// A [Controller] that validates the Authorization header of a request.
|
||||
///
|
||||
/// An instance of this type will validate that the authorization information in an Authorization header is sufficient to access
|
||||
/// the next controller in the channel.
|
||||
///
|
||||
/// For each request, this controller parses the authorization header, validates it with an [AuthValidator] and then create an [Authorization] object
|
||||
/// if successful. The [Request] keeps a reference to this [Authorization] and is then sent to the next controller in the channel.
|
||||
///
|
||||
/// If either parsing or validation fails, a 401 Unauthorized response is sent and the [Request] is removed from the channel.
|
||||
///
|
||||
/// Parsing occurs according to [parser]. The resulting value (e.g., username and password) is sent to [validator].
|
||||
/// [validator] verifies this value (e.g., lookup a user in the database and verify their password matches).
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// router
|
||||
/// .route("/protected-route")
|
||||
/// .link(() =>new Authorizer.bearer(authServer))
|
||||
/// .link(() => new ProtectedResourceController());
|
||||
class Authorizer extends Controller {
|
||||
/// Creates an instance of [Authorizer].
|
||||
///
|
||||
/// Use this constructor to provide custom [AuthorizationParser]s.
|
||||
///
|
||||
/// By default, this instance will parse bearer tokens from the authorization header, e.g.:
|
||||
///
|
||||
/// Authorization: Bearer ap9ijlarlkz8jIOa9laweo
|
||||
///
|
||||
/// If [scopes] is provided, the authorization granted must have access to *all* scopes according to [validator].
|
||||
Authorizer(
|
||||
this.validator, {
|
||||
this.parser = const AuthorizationBearerParser(),
|
||||
List<String>? scopes,
|
||||
}) : scopes = scopes?.map((s) => AuthScope(s)).toList();
|
||||
|
||||
/// Creates an instance of [Authorizer] with Basic Authentication parsing.
|
||||
///
|
||||
/// Parses a username and password from the request's Basic Authentication data in the Authorization header, e.g.:
|
||||
///
|
||||
/// Authorization: Basic base64(username:password)
|
||||
Authorizer.basic(AuthValidator? validator)
|
||||
: this(validator, parser: const AuthorizationBasicParser());
|
||||
|
||||
/// Creates an instance of [Authorizer] with Bearer token parsing.
|
||||
///
|
||||
/// Parses a bearer token from the request's Authorization header, e.g.
|
||||
///
|
||||
/// Authorization: Bearer ap9ijlarlkz8jIOa9laweo
|
||||
///
|
||||
/// If [scopes] is provided, the bearer token must have access to *all* scopes according to [validator].
|
||||
Authorizer.bearer(AuthValidator? validator, {List<String>? scopes})
|
||||
: this(
|
||||
validator,
|
||||
parser: const AuthorizationBearerParser(),
|
||||
scopes: scopes,
|
||||
);
|
||||
|
||||
/// The validating authorization object.
|
||||
///
|
||||
/// This object will check credentials parsed from the Authorization header and produce an
|
||||
/// [Authorization] object representing the authorization the credentials have. It may also
|
||||
/// reject a request. This is typically an instance of [AuthServer].
|
||||
final AuthValidator? validator;
|
||||
|
||||
/// The list of required scopes.
|
||||
///
|
||||
/// If [validator] grants scope-limited authorizations (e.g., OAuth2 bearer tokens), the authorization
|
||||
/// provided by the request's header must have access to all [scopes] in order to move on to the next controller.
|
||||
///
|
||||
/// This property is set with a list of scope strings in a constructor. Each scope string is parsed into
|
||||
/// an [AuthScope] and added to this list.
|
||||
final List<AuthScope>? scopes;
|
||||
|
||||
/// Parses the Authorization header.
|
||||
///
|
||||
/// The parser determines how to interpret the data in the Authorization header. Concrete subclasses
|
||||
/// are [AuthorizationBasicParser] and [AuthorizationBearerParser].
|
||||
///
|
||||
/// Once parsed, the parsed value is validated by [validator].
|
||||
final AuthorizationParser parser;
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> handle(Request request) async {
|
||||
final authData = request.raw.headers.value(HttpHeaders.authorizationHeader);
|
||||
if (authData == null) {
|
||||
return Response.unauthorized();
|
||||
}
|
||||
|
||||
try {
|
||||
final value = parser.parse(authData);
|
||||
request.authorization =
|
||||
await validator!.validate(parser, value, requiredScope: scopes);
|
||||
if (request.authorization == null) {
|
||||
return Response.unauthorized();
|
||||
}
|
||||
|
||||
_addScopeRequirementModifier(request);
|
||||
} on AuthorizationParserException catch (e) {
|
||||
return _responseFromParseException(e);
|
||||
} on AuthServerException catch (e) {
|
||||
if (e.reason == AuthRequestError.invalidScope) {
|
||||
return Response.forbidden(
|
||||
body: {
|
||||
"error": "insufficient_scope",
|
||||
"scope": scopes!.map((s) => s.toString()).join(" ")
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Response.unauthorized();
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
Response _responseFromParseException(AuthorizationParserException e) {
|
||||
switch (e.reason) {
|
||||
case AuthorizationParserExceptionReason.malformed:
|
||||
return Response.badRequest(
|
||||
body: {"error": "invalid_authorization_header"},
|
||||
);
|
||||
case AuthorizationParserExceptionReason.missing:
|
||||
return Response.unauthorized();
|
||||
default:
|
||||
return Response.serverError();
|
||||
}
|
||||
}
|
||||
|
||||
void _addScopeRequirementModifier(Request request) {
|
||||
// If a controller returns a 403 because of invalid scope,
|
||||
// this Authorizer adds its required scope as well.
|
||||
if (scopes != null) {
|
||||
request.addResponseModifier((resp) {
|
||||
if (resp.statusCode == 403 && resp.body is Map) {
|
||||
final body = resp.body as Map<String, dynamic>;
|
||||
if (body.containsKey("scope")) {
|
||||
final declaredScopes = (body["scope"] as String).split(" ");
|
||||
final scopesToAdd = scopes!
|
||||
.map((s) => s.toString())
|
||||
.where((s) => !declaredScopes.contains(s));
|
||||
body["scope"] =
|
||||
[scopesToAdd, declaredScopes].expand((i) => i).join(" ");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
super.documentComponents(context);
|
||||
|
||||
context.responses.register(
|
||||
"InsufficientScope",
|
||||
APIResponse(
|
||||
"The provided credentials or bearer token have insufficient permission to access this route.",
|
||||
content: {
|
||||
"application/json": APIMediaType(
|
||||
schema: APISchemaObject.object({
|
||||
"error": APISchemaObject.string(),
|
||||
"scope": APISchemaObject.string()
|
||||
..description = "The required scope for this operation."
|
||||
}),
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
context.responses.register(
|
||||
"InsufficientAccess",
|
||||
APIResponse(
|
||||
"The provided credentials or bearer token are not authorized for this request.",
|
||||
content: {
|
||||
"application/json": APIMediaType(
|
||||
schema: APISchemaObject.object(
|
||||
{"error": APISchemaObject.string()},
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
context.responses.register(
|
||||
"MalformedAuthorizationHeader",
|
||||
APIResponse(
|
||||
"The provided Authorization header was malformed.",
|
||||
content: {
|
||||
"application/json": APIMediaType(
|
||||
schema: APISchemaObject.object(
|
||||
{"error": APISchemaObject.string()},
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
final operations = super.documentOperations(context, route, path);
|
||||
|
||||
operations.forEach((_, op) {
|
||||
op.addResponse(400, context.responses["MalformedAuthorizationHeader"]);
|
||||
op.addResponse(401, context.responses["InsufficientAccess"]);
|
||||
op.addResponse(403, context.responses["InsufficientScope"]);
|
||||
|
||||
final requirements = validator!
|
||||
.documentRequirementsForAuthorizer(context, this, scopes: scopes);
|
||||
for (final req in requirements) {
|
||||
op.addSecurityRequirement(req);
|
||||
}
|
||||
});
|
||||
|
||||
return operations;
|
||||
}
|
||||
}
|
121
packages/auth/lib/src/exceptions.dart
Normal file
121
packages/auth/lib/src/exceptions.dart
Normal file
|
@ -0,0 +1,121 @@
|
|||
import 'package:protevus_auth/auth.dart';
|
||||
|
||||
/// An exception thrown by [AuthServer].
|
||||
class AuthServerException implements Exception {
|
||||
AuthServerException(this.reason, this.client);
|
||||
|
||||
/// Returns a string suitable to be included in a query string or JSON response body
|
||||
/// to indicate the error during processing an OAuth 2.0 request.
|
||||
static String errorString(AuthRequestError error) {
|
||||
switch (error) {
|
||||
case AuthRequestError.invalidRequest:
|
||||
return "invalid_request";
|
||||
case AuthRequestError.invalidClient:
|
||||
return "invalid_client";
|
||||
case AuthRequestError.invalidGrant:
|
||||
return "invalid_grant";
|
||||
case AuthRequestError.invalidScope:
|
||||
return "invalid_scope";
|
||||
case AuthRequestError.invalidToken:
|
||||
return "invalid_token";
|
||||
|
||||
case AuthRequestError.unsupportedGrantType:
|
||||
return "unsupported_grant_type";
|
||||
case AuthRequestError.unsupportedResponseType:
|
||||
return "unsupported_response_type";
|
||||
|
||||
case AuthRequestError.unauthorizedClient:
|
||||
return "unauthorized_client";
|
||||
case AuthRequestError.accessDenied:
|
||||
return "access_denied";
|
||||
|
||||
case AuthRequestError.serverError:
|
||||
return "server_error";
|
||||
case AuthRequestError.temporarilyUnavailable:
|
||||
return "temporarily_unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
AuthRequestError reason;
|
||||
AuthClient? client;
|
||||
|
||||
String get reasonString {
|
||||
return errorString(reason);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "AuthServerException: $reason $client";
|
||||
}
|
||||
}
|
||||
|
||||
/// The possible errors as defined by the OAuth 2.0 specification.
|
||||
///
|
||||
/// Auth endpoints will use this list of values to determine the response sent back
|
||||
/// to a client upon a failed request.
|
||||
enum AuthRequestError {
|
||||
/// The request was invalid...
|
||||
///
|
||||
/// The request is missing a required parameter, includes an
|
||||
/// unsupported parameter value (other than grant type),
|
||||
/// repeats a parameter, includes multiple credentials,
|
||||
/// utilizes more than one mechanism for authenticating the
|
||||
/// client, or is otherwise malformed.
|
||||
invalidRequest,
|
||||
|
||||
/// The client was invalid...
|
||||
///
|
||||
/// Client authentication failed (e.g., unknown client, no
|
||||
/// client authentication included, or unsupported
|
||||
/// authentication method). The authorization server MAY
|
||||
/// return an HTTP 401 (Unauthorized) status code to indicate
|
||||
/// which HTTP authentication schemes are supported. If the
|
||||
/// client attempted to authenticate via the "Authorization"
|
||||
/// request header field, the authorization server MUST
|
||||
/// respond with an HTTP 401 (Unauthorized) status code and
|
||||
/// include the "WWW-Authenticate" response header field
|
||||
/// matching the authentication scheme used by the client.
|
||||
invalidClient,
|
||||
|
||||
/// The grant was invalid...
|
||||
///
|
||||
/// The provided authorization grant (e.g., authorization
|
||||
/// code, resource owner credentials) or refresh token is
|
||||
/// invalid, expired, revoked, does not match the redirection
|
||||
/// URI used in the authorization request, or was issued to
|
||||
/// another client.
|
||||
invalidGrant,
|
||||
|
||||
/// The requested scope is invalid, unknown, malformed, or exceeds the scope granted by the resource owner.
|
||||
///
|
||||
invalidScope,
|
||||
|
||||
/// The authorization grant type is not supported by the authorization server.
|
||||
///
|
||||
unsupportedGrantType,
|
||||
|
||||
/// The authorization server does not support obtaining an authorization code using this method.
|
||||
///
|
||||
unsupportedResponseType,
|
||||
|
||||
/// The authenticated client is not authorized to use this authorization grant type.
|
||||
///
|
||||
unauthorizedClient,
|
||||
|
||||
/// The resource owner or authorization server denied the request.
|
||||
///
|
||||
accessDenied,
|
||||
|
||||
/// The server encountered an error during processing the request.
|
||||
///
|
||||
serverError,
|
||||
|
||||
/// The server is temporarily unable to fulfill the request.
|
||||
///
|
||||
temporarilyUnavailable,
|
||||
|
||||
/// Indicates that the token is invalid.
|
||||
///
|
||||
/// This particular error reason is not part of the OAuth 2.0 spec.
|
||||
invalidToken
|
||||
}
|
541
packages/auth/lib/src/objects.dart
Normal file
541
packages/auth/lib/src/objects.dart
Normal file
|
@ -0,0 +1,541 @@
|
|||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Represents an OAuth 2.0 client ID and secret pair.
|
||||
///
|
||||
/// See the conduit/managed_auth library for a concrete implementation of this type.
|
||||
///
|
||||
/// Use the command line tool `conduit auth` to create instances of this type and store them to a database.
|
||||
class AuthClient {
|
||||
/// Creates an instance of [AuthClient].
|
||||
///
|
||||
/// [id] must not be null. [hashedSecret] and [salt] must either both be null or both be valid values. If [hashedSecret] and [salt]
|
||||
/// are valid values, this client is a confidential client. Otherwise, the client is public. The terms 'confidential' and 'public'
|
||||
/// are described by the OAuth 2.0 specification.
|
||||
///
|
||||
/// If this client supports scopes, [allowedScopes] must contain a list of scopes that tokens may request when authorized
|
||||
/// by this client.
|
||||
AuthClient(
|
||||
String id,
|
||||
String? hashedSecret,
|
||||
String? salt, {
|
||||
List<AuthScope>? allowedScopes,
|
||||
}) : this.withRedirectURI(
|
||||
id,
|
||||
hashedSecret,
|
||||
salt,
|
||||
null,
|
||||
allowedScopes: allowedScopes,
|
||||
);
|
||||
|
||||
/// Creates an instance of a public [AuthClient].
|
||||
AuthClient.public(String id,
|
||||
{List<AuthScope>? allowedScopes, String? redirectURI})
|
||||
: this.withRedirectURI(
|
||||
id,
|
||||
null,
|
||||
null,
|
||||
redirectURI,
|
||||
allowedScopes: allowedScopes,
|
||||
);
|
||||
|
||||
/// Creates an instance of [AuthClient] that uses the authorization code grant flow.
|
||||
///
|
||||
/// All values must be non-null. This is confidential client.
|
||||
AuthClient.withRedirectURI(
|
||||
this.id,
|
||||
this.hashedSecret,
|
||||
this.salt,
|
||||
this.redirectURI, {
|
||||
List<AuthScope>? allowedScopes,
|
||||
}) {
|
||||
this.allowedScopes = allowedScopes;
|
||||
}
|
||||
|
||||
List<AuthScope>? _allowedScopes;
|
||||
|
||||
/// The ID of the client.
|
||||
final String id;
|
||||
|
||||
/// The hashed secret of the client.
|
||||
///
|
||||
/// This value may be null if the client is public. See [isPublic].
|
||||
String? hashedSecret;
|
||||
|
||||
/// The salt [hashedSecret] was hashed with.
|
||||
///
|
||||
/// This value may be null if the client is public. See [isPublic].
|
||||
String? salt;
|
||||
|
||||
/// The redirection URI for authorization codes and/or tokens.
|
||||
///
|
||||
/// This value may be null if the client doesn't support the authorization code flow.
|
||||
String? redirectURI;
|
||||
|
||||
/// The list of scopes available when authorizing with this client.
|
||||
///
|
||||
/// Scoping is determined by this instance; i.e. the authorizing client determines which scopes a token
|
||||
/// has. This list contains all valid scopes for this client. If null, client does not support scopes
|
||||
/// and all access tokens have same authorization.
|
||||
List<AuthScope>? get allowedScopes => _allowedScopes;
|
||||
set allowedScopes(List<AuthScope>? scopes) {
|
||||
_allowedScopes = scopes?.where((s) {
|
||||
return !scopes.any(
|
||||
(otherScope) =>
|
||||
s.isSubsetOrEqualTo(otherScope) && !s.isExactlyScope(otherScope),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Whether or not this instance allows scoping or not.
|
||||
///
|
||||
/// In application's that do not use authorization scopes, this will return false.
|
||||
/// Otherwise, will return true.
|
||||
bool get supportsScopes => allowedScopes != null;
|
||||
|
||||
/// Whether or not this client can issue tokens for the provided [scope].
|
||||
bool allowsScope(AuthScope scope) {
|
||||
return allowedScopes
|
||||
?.any((clientScope) => scope.isSubsetOrEqualTo(clientScope)) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// Whether or not this is a public or confidential client.
|
||||
///
|
||||
/// Public clients do not have a client secret and are used for clients that can't store
|
||||
/// their secret confidentially, i.e. JavaScript browser applications.
|
||||
bool get isPublic => hashedSecret == null;
|
||||
|
||||
/// Whether or not this is a public or confidential client.
|
||||
///
|
||||
/// Confidential clients have a client secret that must be used when authenticating with
|
||||
/// a client-authenticated request. Confidential clients are used when you can
|
||||
/// be sure that the client secret cannot be viewed by anyone outside of the developer.
|
||||
bool get isConfidential => hashedSecret != null;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "AuthClient (${isPublic ? "public" : "confidental"}): $id $redirectURI";
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an OAuth 2.0 token.
|
||||
///
|
||||
/// [AuthServerDelegate] and [AuthServer] will exchange OAuth 2.0
|
||||
/// tokens through instances of this type.
|
||||
///
|
||||
/// See the `package:conduit_core/managed_auth` library for a concrete implementation of this type.
|
||||
class AuthToken {
|
||||
/// The value to be passed as a Bearer Authorization header.
|
||||
String? accessToken;
|
||||
|
||||
/// The value to be passed for refreshing a token.
|
||||
String? refreshToken;
|
||||
|
||||
/// The time this token was issued on.
|
||||
DateTime? issueDate;
|
||||
|
||||
/// The time when this token expires.
|
||||
DateTime? expirationDate;
|
||||
|
||||
/// The type of token, currently only 'bearer' is valid.
|
||||
String? type;
|
||||
|
||||
/// The identifier of the resource owner.
|
||||
///
|
||||
/// Tokens are owned by a resource owner, typically a User, Profile or Account
|
||||
/// in an application. This value is the primary key or identifying value of those
|
||||
/// instances.
|
||||
int? resourceOwnerIdentifier;
|
||||
|
||||
/// The client ID this token was issued from.
|
||||
late String clientID;
|
||||
|
||||
/// Scopes this token has access to.
|
||||
List<AuthScope>? scopes;
|
||||
|
||||
/// Whether or not this token is expired by evaluated [expirationDate].
|
||||
bool get isExpired {
|
||||
return expirationDate!.difference(DateTime.now().toUtc()).inSeconds <= 0;
|
||||
}
|
||||
|
||||
/// Emits this instance as a [Map] according to the OAuth 2.0 specification.
|
||||
Map<String, dynamic> asMap() {
|
||||
final map = {
|
||||
"access_token": accessToken,
|
||||
"token_type": type,
|
||||
"expires_in":
|
||||
expirationDate!.difference(DateTime.now().toUtc()).inSeconds,
|
||||
};
|
||||
|
||||
if (refreshToken != null) {
|
||||
map["refresh_token"] = refreshToken;
|
||||
}
|
||||
|
||||
if (scopes != null) {
|
||||
map["scope"] = scopes!.map((s) => s.toString()).join(" ");
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an OAuth 2.0 authorization code.
|
||||
///
|
||||
/// [AuthServerDelegate] and [AuthServer] will exchange OAuth 2.0
|
||||
/// authorization codes through instances of this type.
|
||||
///
|
||||
/// See the conduit/managed_auth library for a concrete implementation of this type.
|
||||
class AuthCode {
|
||||
/// The actual one-time code used to exchange for tokens.
|
||||
String? code;
|
||||
|
||||
/// The client ID the authorization code was issued under.
|
||||
late String clientID;
|
||||
|
||||
/// The identifier of the resource owner.
|
||||
///
|
||||
/// Authorization codes are owned by a resource owner, typically a User, Profile or Account
|
||||
/// in an application. This value is the primary key or identifying value of those
|
||||
/// instances.
|
||||
int? resourceOwnerIdentifier;
|
||||
|
||||
/// The timestamp this authorization code was issued on.
|
||||
DateTime? issueDate;
|
||||
|
||||
/// When this authorization code expires, recommended for 10 minutes after issue date.
|
||||
DateTime? expirationDate;
|
||||
|
||||
/// Whether or not this authorization code has already been exchanged for a token.
|
||||
bool? hasBeenExchanged;
|
||||
|
||||
/// Scopes the exchanged token will have.
|
||||
List<AuthScope>? requestedScopes;
|
||||
|
||||
/// Whether or not this code has expired yet, according to its [expirationDate].
|
||||
bool get isExpired {
|
||||
return expirationDate!.difference(DateTime.now().toUtc()).inSeconds <= 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// Authorization information for a [Request] after it has passed through an [Authorizer].
|
||||
///
|
||||
/// After a request has passed through an [Authorizer], an instance of this type
|
||||
/// is created and attached to the request (see [Request.authorization]). Instances of this type contain the information
|
||||
/// that the [Authorizer] obtained from an [AuthValidator] (typically an [AuthServer])
|
||||
/// about the validity of the credentials in a request.
|
||||
class Authorization {
|
||||
/// Creates an instance of a [Authorization].
|
||||
Authorization(
|
||||
this.clientID,
|
||||
this.ownerID,
|
||||
this.validator, {
|
||||
this.credentials,
|
||||
this.scopes,
|
||||
});
|
||||
|
||||
/// The client ID the permission was granted under.
|
||||
final String clientID;
|
||||
|
||||
/// The identifier for the owner of the resource, if provided.
|
||||
///
|
||||
/// If this instance refers to the authorization of a resource owner, this value will
|
||||
/// be its identifying value. For example, in an application where a 'User' is stored in a database,
|
||||
/// this value would be the primary key of that user.
|
||||
///
|
||||
/// If this authorization does not refer to a specific resource owner, this value will be null.
|
||||
final int? ownerID;
|
||||
|
||||
/// The [AuthValidator] that granted this permission.
|
||||
final AuthValidator? validator;
|
||||
|
||||
/// Basic authorization credentials, if provided.
|
||||
///
|
||||
/// If this instance represents the authorization header of a request with basic authorization credentials,
|
||||
/// the parsed credentials will be available in this property. Otherwise, this value is null.
|
||||
final AuthBasicCredentials? credentials;
|
||||
|
||||
/// The list of scopes this authorization has access to.
|
||||
///
|
||||
/// If the access token used to create this instance has scope,
|
||||
/// those scopes will be available here. Otherwise, null.
|
||||
List<AuthScope>? scopes;
|
||||
|
||||
/// Whether or not this instance has access to a specific scope.
|
||||
///
|
||||
/// This method checks each element in [scopes] for any that gives privileges
|
||||
/// to access [scope].
|
||||
bool isAuthorizedForScope(String scope) {
|
||||
final asScope = AuthScope(scope);
|
||||
return scopes?.any(asScope.isSubsetOrEqualTo) ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Instances represent OAuth 2.0 scope.
|
||||
///
|
||||
/// An OAuth 2.0 token may optionally have authorization scopes. An authorization scope provides more granular
|
||||
/// authorization to protected resources. Without authorization scopes, any valid token can pass through an
|
||||
/// [Authorizer.bearer]. Scopes allow [Authorizer]s to restrict access to routes that do not have the
|
||||
/// appropriate scope values.
|
||||
///
|
||||
/// An [AuthClient] has a list of valid scopes (see `conduit auth` tool). An access token issued for an [AuthClient] may ask for
|
||||
/// any of the scopes the client provides. Scopes are then granted to the access token. An [Authorizer] may specify
|
||||
/// a one or more required scopes that a token must have to pass to the next controller.
|
||||
class AuthScope {
|
||||
/// Creates an instance of this type from [scopeString].
|
||||
///
|
||||
/// A simple authorization scope string is a single keyword. Valid characters are
|
||||
///
|
||||
/// A-Za-z0-9!#\$%&'`()*+,./:;<=>?@[]^_{|}-.
|
||||
///
|
||||
/// For example, 'account' is a valid scope. An [Authorizer] can require an access token to have
|
||||
/// the 'account' scope to pass through it. Access tokens without the 'account' scope are unauthorized.
|
||||
///
|
||||
/// More advanced scopes may contain multiple segments and a modifier. For example, the following are valid scopes:
|
||||
///
|
||||
/// user
|
||||
/// user:settings
|
||||
/// user:posts
|
||||
/// user:posts.readonly
|
||||
///
|
||||
/// Segments are delimited by the colon character (`:`). Segments allow more granular scoping options. Each segment adds a
|
||||
/// restriction to the segment prior to it. For example, the scope `user`
|
||||
/// would allow all user actions, whereas `user:settings` would only allow access to a user's settings. Routes that are secured
|
||||
/// to either `user:settings` or `user:posts.readonly` are accessible by an access token with `user` scope. A token with `user:settings`
|
||||
/// would not be able to access a route limited to `user:posts`.
|
||||
///
|
||||
/// A modifier is an additional restrictive measure and follows scope segments and the dot character (`.`). A scope may only
|
||||
/// have one modifier at the very end of the scope. A modifier can be any string, as long as its characters are in the above
|
||||
/// list of valid characters. A modifier adds an additional restriction to a scope, without having to make up a new segment.
|
||||
/// An example is the 'readonly' modifier above. A route that requires `user:posts.readonly` would allow passage when the token
|
||||
/// has `user`, `user:posts` or `user:posts.readonly`. A route that required `user:posts` would not allow `user:posts.readonly`.
|
||||
factory AuthScope(String scopeString) {
|
||||
final cached = _cache[scopeString];
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final scope = AuthScope._parse(scopeString);
|
||||
_cache[scopeString] = scope;
|
||||
return scope;
|
||||
}
|
||||
|
||||
factory AuthScope._parse(String scopeString) {
|
||||
if (scopeString.isEmpty) {
|
||||
throw FormatException(
|
||||
"Invalid AuthScope. May not an empty string.",
|
||||
scopeString,
|
||||
);
|
||||
}
|
||||
|
||||
for (final c in scopeString.codeUnits) {
|
||||
if (!(c == 33 || (c >= 35 && c <= 91) || (c >= 93 && c <= 126))) {
|
||||
throw FormatException(
|
||||
"Invalid authorization scope. May only contain "
|
||||
"the following characters: A-Za-z0-9!#\$%&'`()*+,./:;<=>?@[]^_{|}-",
|
||||
scopeString,
|
||||
scopeString.codeUnits.indexOf(c),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final segments = _parseSegments(scopeString);
|
||||
final lastModifier = segments.last.modifier;
|
||||
|
||||
return AuthScope._(scopeString, segments, lastModifier);
|
||||
}
|
||||
|
||||
const AuthScope._(this._scopeString, this._segments, this._lastModifier);
|
||||
|
||||
/// Signifies 'any' scope in [AuthServerDelegate.getAllowedScopes].
|
||||
///
|
||||
/// See [AuthServerDelegate.getAllowedScopes] for more details.
|
||||
static const List<AuthScope> any = [
|
||||
AuthScope._("_scope:_constant:_marker", [], null)
|
||||
];
|
||||
|
||||
/// Returns true if that [providedScopes] fulfills [requiredScopes].
|
||||
///
|
||||
/// For all [requiredScopes], there must be a scope in [requiredScopes] that meets or exceeds
|
||||
/// that scope for this method to return true. If [requiredScopes] is null, this method
|
||||
/// return true regardless of [providedScopes].
|
||||
static bool verify(
|
||||
List<AuthScope>? requiredScopes,
|
||||
List<AuthScope>? providedScopes,
|
||||
) {
|
||||
if (requiredScopes == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return requiredScopes.every((requiredScope) {
|
||||
final tokenHasValidScope = providedScopes
|
||||
?.any((tokenScope) => requiredScope.isSubsetOrEqualTo(tokenScope));
|
||||
|
||||
return tokenHasValidScope ?? false;
|
||||
});
|
||||
}
|
||||
|
||||
static final Map<String, AuthScope> _cache = {};
|
||||
|
||||
final String _scopeString;
|
||||
|
||||
/// Individual segments, separated by `:` character, of this instance.
|
||||
///
|
||||
/// Will always have a length of at least 1.
|
||||
Iterable<String?> get segments => _segments.map((s) => s.name);
|
||||
|
||||
/// The modifier of this scope, if it exists.
|
||||
///
|
||||
/// If this instance does not have a modifier, returns null.
|
||||
String? get modifier => _lastModifier;
|
||||
|
||||
final List<_AuthScopeSegment> _segments;
|
||||
final String? _lastModifier;
|
||||
|
||||
static List<_AuthScopeSegment> _parseSegments(String scopeString) {
|
||||
if (scopeString.isEmpty) {
|
||||
throw FormatException(
|
||||
"Invalid AuthScope. May not be empty string.",
|
||||
scopeString,
|
||||
);
|
||||
}
|
||||
|
||||
final elements =
|
||||
scopeString.split(":").map((seg) => _AuthScopeSegment(seg)).toList();
|
||||
|
||||
var scannedOffset = 0;
|
||||
for (var i = 0; i < elements.length - 1; i++) {
|
||||
if (elements[i].modifier != null) {
|
||||
throw FormatException(
|
||||
"Invalid AuthScope. May only contain modifiers on the last segment.",
|
||||
scopeString,
|
||||
scannedOffset,
|
||||
);
|
||||
}
|
||||
|
||||
if (elements[i].name == "") {
|
||||
throw FormatException(
|
||||
"Invalid AuthScope. May not contain empty segments or, leading or trailing colons.",
|
||||
scopeString,
|
||||
scannedOffset,
|
||||
);
|
||||
}
|
||||
|
||||
scannedOffset += elements[i].toString().length + 1;
|
||||
}
|
||||
|
||||
if (elements.last.name == "") {
|
||||
throw FormatException(
|
||||
"Invalid AuthScope. May not contain empty segments.",
|
||||
scopeString,
|
||||
scannedOffset,
|
||||
);
|
||||
}
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
/// Whether or not this instance is a subset or equal to [incomingScope].
|
||||
///
|
||||
/// The scope `users:posts` is a subset of `users`.
|
||||
///
|
||||
/// This check is used to determine if an [Authorizer] can allow a [Request]
|
||||
/// to pass if the [Request]'s [Request.authorization] has a scope that has
|
||||
/// the same or more scope than the required scope of an [Authorizer].
|
||||
bool isSubsetOrEqualTo(AuthScope incomingScope) {
|
||||
if (incomingScope._lastModifier != null) {
|
||||
// If the modifier of the incoming scope is restrictive,
|
||||
// and this scope requires no restrictions, then it's not allowed.
|
||||
if (_lastModifier == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the incoming scope's modifier doesn't match this one,
|
||||
// then we also don't have access.
|
||||
if (_lastModifier != incomingScope._lastModifier) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
final thisIterator = _segments.iterator;
|
||||
for (final incomingSegment in incomingScope._segments) {
|
||||
// If the incoming scope is more restrictive than this scope,
|
||||
// then it's not allowed.
|
||||
if (!thisIterator.moveNext()) {
|
||||
return false;
|
||||
}
|
||||
final current = thisIterator.current;
|
||||
|
||||
// If we have a mismatch here, then we're going
|
||||
// down the wrong path.
|
||||
if (incomingSegment.name != current.name) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Alias of [isSubsetOrEqualTo].
|
||||
@Deprecated('Use AuthScope.isSubsetOrEqualTo() instead')
|
||||
bool allowsScope(AuthScope incomingScope) => isSubsetOrEqualTo(incomingScope);
|
||||
|
||||
/// String variant of [isSubsetOrEqualTo].
|
||||
///
|
||||
/// Parses an instance of this type from [scopeString] and invokes
|
||||
/// [isSubsetOrEqualTo].
|
||||
bool allows(String scopeString) => isSubsetOrEqualTo(AuthScope(scopeString));
|
||||
|
||||
/// Whether or not two scopes are exactly the same.
|
||||
bool isExactlyScope(AuthScope scope) {
|
||||
final incomingIterator = scope._segments.iterator;
|
||||
for (final segment in _segments) {
|
||||
/// the scope has less segments so no match.
|
||||
if (!incomingIterator.moveNext()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final incomingSegment = incomingIterator.current;
|
||||
|
||||
if (incomingSegment.name != segment.name ||
|
||||
incomingSegment.modifier != segment.modifier) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// String variant of [isExactlyScope].
|
||||
///
|
||||
/// Parses an instance of this type from [scopeString] and invokes [isExactlyScope].
|
||||
bool isExactly(String scopeString) {
|
||||
return isExactlyScope(AuthScope(scopeString));
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => _scopeString;
|
||||
}
|
||||
|
||||
class _AuthScopeSegment {
|
||||
_AuthScopeSegment(String segment) {
|
||||
final split = segment.split(".");
|
||||
if (split.length == 2) {
|
||||
name = split.first;
|
||||
modifier = split.last;
|
||||
} else {
|
||||
name = segment;
|
||||
}
|
||||
}
|
||||
|
||||
String? name;
|
||||
String? modifier;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (modifier == null) {
|
||||
return name!;
|
||||
}
|
||||
return "$name.$modifier";
|
||||
}
|
||||
}
|
154
packages/auth/lib/src/protocols.dart
Normal file
154
packages/auth/lib/src/protocols.dart
Normal file
|
@ -0,0 +1,154 @@
|
|||
import 'dart:async';
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
|
||||
/// The properties of an OAuth 2.0 Resource Owner.
|
||||
///
|
||||
/// Your application's 'user' type must implement the methods declared in this interface. [AuthServer] can
|
||||
/// validate the credentials of a [ResourceOwner] to grant authorization codes and access tokens on behalf of that
|
||||
/// owner.
|
||||
abstract class ResourceOwner {
|
||||
/// The username of the resource owner.
|
||||
///
|
||||
/// This value must be unique amongst all resource owners. It is often an email address. This value
|
||||
/// is used by authenticating users to identify their account.
|
||||
String? username;
|
||||
|
||||
/// The hashed password of this instance.
|
||||
String? hashedPassword;
|
||||
|
||||
/// The salt the [hashedPassword] was hashed with.
|
||||
String? salt;
|
||||
|
||||
/// A unique identifier of this resource owner.
|
||||
///
|
||||
/// This unique identifier is used by [AuthServer] to associate authorization codes and access tokens with
|
||||
/// this resource owner.
|
||||
int? get id;
|
||||
}
|
||||
|
||||
/// The methods used by an [AuthServer] to store information and customize behavior related to authorization.
|
||||
///
|
||||
/// An [AuthServer] requires an instance of this type to manage storage of [ResourceOwner]s, [AuthToken], [AuthCode],
|
||||
/// and [AuthClient]s. You may also customize the token format or add more granular authorization scope rules.
|
||||
///
|
||||
/// Prefer to use `ManagedAuthDelegate` from 'package:conduit_core/managed_auth.dart' instead of implementing this interface;
|
||||
/// there are important details to consider and test when implementing this interface.
|
||||
abstract class AuthServerDelegate {
|
||||
/// Must return a [ResourceOwner] for a [username].
|
||||
///
|
||||
/// This method must return an instance of [ResourceOwner] if one exists for [username]. Otherwise, it must return null.
|
||||
///
|
||||
/// Every property declared by [ResourceOwner] must be non-null in the return value.
|
||||
///
|
||||
/// [server] is the [AuthServer] invoking this method.
|
||||
FutureOr<ResourceOwner?> getResourceOwner(AuthServer server, String username);
|
||||
|
||||
/// Must store [client].
|
||||
///
|
||||
/// [client] must be returned by [getClient] after this method has been invoked, and until (if ever)
|
||||
/// [removeClient] is invoked.
|
||||
FutureOr addClient(AuthServer server, AuthClient client);
|
||||
|
||||
/// Must return [AuthClient] for a client ID.
|
||||
///
|
||||
/// This method must return an instance of [AuthClient] if one exists for [clientID]. Otherwise, it must return null.
|
||||
/// [server] is the [AuthServer] requesting the [AuthClient].
|
||||
FutureOr<AuthClient?> getClient(AuthServer server, String clientID);
|
||||
|
||||
/// Removes an [AuthClient] for a client ID.
|
||||
///
|
||||
/// This method must delete the [AuthClient] for [clientID]. Subsequent requests to this
|
||||
/// instance for [getClient] must return null after this method completes. If there is no
|
||||
/// matching [clientID], this method may choose whether to throw an exception or fail silently.
|
||||
///
|
||||
/// [server] is the [AuthServer] requesting the [AuthClient].
|
||||
FutureOr removeClient(AuthServer server, String clientID);
|
||||
|
||||
/// Returns a [AuthToken] searching by its access token or refresh token.
|
||||
///
|
||||
/// Exactly one of [byAccessToken] and [byRefreshToken] may be non-null, if not, this method must throw an error.
|
||||
///
|
||||
/// If [byAccessToken] is not-null and there exists a matching [AuthToken.accessToken], return that token.
|
||||
/// If [byRefreshToken] is not-null and there exists a matching [AuthToken.refreshToken], return that token.
|
||||
///
|
||||
/// If no match is found, return null.
|
||||
///
|
||||
/// [server] is the [AuthServer] requesting the [AuthToken].
|
||||
FutureOr<AuthToken?> getToken(
|
||||
AuthServer server, {
|
||||
String? byAccessToken,
|
||||
String? byRefreshToken,
|
||||
});
|
||||
|
||||
/// This method must delete all [AuthToken] and [AuthCode]s for a [ResourceOwner].
|
||||
///
|
||||
/// [server] is the requesting [AuthServer]. [resourceOwnerID] is the [ResourceOwner.id].
|
||||
FutureOr removeTokens(AuthServer server, int resourceOwnerID);
|
||||
|
||||
/// Must delete a [AuthToken] granted by [grantedByCode].
|
||||
///
|
||||
/// If an [AuthToken] has been granted by exchanging [AuthCode], that token must be revoked
|
||||
/// and can no longer be used to authorize access to a resource. [grantedByCode] should
|
||||
/// also be removed.
|
||||
///
|
||||
/// This method is invoked when attempting to exchange an authorization code that has already granted a token.
|
||||
FutureOr removeToken(AuthServer server, AuthCode grantedByCode);
|
||||
|
||||
/// Must store [token].
|
||||
///
|
||||
/// [token] must be stored such that it is accessible from [getToken], and until it is either
|
||||
/// revoked via [removeToken] or [removeTokens], or until it has expired and can reasonably
|
||||
/// be believed to no longer be in use.
|
||||
///
|
||||
/// You may alter [token] prior to storing it. This may include replacing [AuthToken.accessToken] with another token
|
||||
/// format. The default token format will be a random 32 character string.
|
||||
///
|
||||
/// If this token was granted through an authorization code, [issuedFrom] is that code. Otherwise, [issuedFrom]
|
||||
/// is null.
|
||||
FutureOr addToken(AuthServer server, AuthToken token, {AuthCode? issuedFrom});
|
||||
|
||||
/// Must update [AuthToken] with [newAccessToken, [newIssueDate, [newExpirationDate].
|
||||
///
|
||||
/// This method must must update an existing [AuthToken], found by [oldAccessToken],
|
||||
/// with the values [newAccessToken], [newIssueDate] and [newExpirationDate].
|
||||
///
|
||||
/// You may alter the token in addition to the provided values, and you may override the provided values.
|
||||
/// [newAccessToken] defaults to a random 32 character string.
|
||||
FutureOr updateToken(
|
||||
AuthServer server,
|
||||
String? oldAccessToken,
|
||||
String? newAccessToken,
|
||||
DateTime? newIssueDate,
|
||||
DateTime? newExpirationDate,
|
||||
);
|
||||
|
||||
/// Must store [code].
|
||||
///
|
||||
/// [code] must be accessible until its expiration date.
|
||||
FutureOr addCode(AuthServer server, AuthCode code);
|
||||
|
||||
/// Must return [AuthCode] for its identifiying [code].
|
||||
///
|
||||
/// This must return an instance of [AuthCode] where [AuthCode.code] matches [code].
|
||||
/// Return null if no matching code.
|
||||
FutureOr<AuthCode?> getCode(AuthServer server, String code);
|
||||
|
||||
/// Must remove [AuthCode] identified by [code].
|
||||
///
|
||||
/// The [AuthCode.code] matching [code] must be deleted and no longer accessible.
|
||||
FutureOr removeCode(AuthServer server, String? code);
|
||||
|
||||
/// Returns list of allowed scopes for a given [ResourceOwner].
|
||||
///
|
||||
/// Subclasses override this method to return a list of [AuthScope]s based on some attribute(s) of an [ResourceOwner].
|
||||
/// That [ResourceOwner] is then restricted to only those scopes, even if the authenticating client would allow other scopes
|
||||
/// or scopes with higher privileges.
|
||||
///
|
||||
/// By default, this method returns [AuthScope.any] - any [ResourceOwner] being authenticated has full access to the scopes
|
||||
/// available to the authenticating client.
|
||||
///
|
||||
/// When overriding this method, it is important to note that (by default) only the properties declared by [ResourceOwner]
|
||||
/// will be valid for [owner]. If [owner] has properties that are application-specific (like a `role`),
|
||||
/// [getResourceOwner] must also be overridden to ensure those values are fetched.
|
||||
List<AuthScope>? getAllowedScopes(ResourceOwner owner) => AuthScope.any;
|
||||
}
|
41
packages/auth/lib/src/validator.dart
Normal file
41
packages/auth/lib/src/validator.dart
Normal file
|
@ -0,0 +1,41 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// Instances that implement this type can be used by an [Authorizer] to determine authorization of a request.
|
||||
///
|
||||
/// When an [Authorizer] processes a [Request], it invokes [validate], passing in the parsed Authorization
|
||||
/// header of the [Request].
|
||||
///
|
||||
/// [AuthServer] implements this interface.
|
||||
mixin AuthValidator {
|
||||
/// Returns an [Authorization] if [authorizationData] is valid.
|
||||
///
|
||||
/// This method is invoked by [Authorizer] to validate the Authorization header of a request. [authorizationData]
|
||||
/// is the parsed contents of the Authorization header, while [parser] is the object that parsed the header.
|
||||
///
|
||||
/// If this method returns null, an [Authorizer] will send a 401 Unauthorized response.
|
||||
/// If this method throws an [AuthorizationParserException], a 400 Bad Request response is sent.
|
||||
/// If this method throws an [AuthServerException], an appropriate status code is sent for the details of the exception.
|
||||
///
|
||||
/// If [requiredScope] is provided, a request's authorization must have at least that much scope to pass the [Authorizer].
|
||||
FutureOr<Authorization?> validate<T>(
|
||||
AuthorizationParser<T> parser,
|
||||
T authorizationData, {
|
||||
List<AuthScope>? requiredScope,
|
||||
});
|
||||
|
||||
/// Provide [APISecurityRequirement]s for [authorizer].
|
||||
///
|
||||
/// An [Authorizer] that adds security requirements to operations will invoke this method to allow this validator to define those requirements.
|
||||
/// The [Authorizer] must provide the [context] it was given to document the operations, itself and optionally a list of [scopes] required to pass it.
|
||||
List<APISecurityRequirement> documentRequirementsForAuthorizer(
|
||||
APIDocumentContext context,
|
||||
Authorizer authorizer, {
|
||||
List<AuthScope>? scopes,
|
||||
}) =>
|
||||
[];
|
||||
}
|
|
@ -10,6 +10,10 @@ environment:
|
|||
|
||||
# Add regular dependencies here.
|
||||
dependencies:
|
||||
protevus_http: ^0.0.1
|
||||
protevus_openapi: ^0.0.1
|
||||
protevus_hashing: ^0.0.1
|
||||
crypto: ^3.0.3
|
||||
# path: ^1.8.0
|
||||
|
||||
dev_dependencies:
|
||||
|
|
30
packages/config/lib/config.dart
Normal file
30
packages/config/lib/config.dart
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/// Configuration library for the Protevus Platform.
|
||||
///
|
||||
/// This library exports various components related to configuration management,
|
||||
/// including compiler, runtime, and default configurations. It also includes
|
||||
/// utilities for handling intermediate exceptions and mirror properties.
|
||||
///
|
||||
/// The exported modules are:
|
||||
/// - compiler: Handles compilation of configuration files.
|
||||
/// - configuration: Defines the core configuration structure.
|
||||
/// - default_configurations: Provides pre-defined default configurations.
|
||||
/// - intermediate_exception: Manages exceptions during configuration processing.
|
||||
/// - mirror_property: Utilities for reflection-based property handling.
|
||||
/// - runtime: Manages runtime configuration aspects.
|
||||
library config;
|
||||
|
||||
export 'package:protevus_config/src/compiler.dart';
|
||||
export 'package:protevus_config/src/configuration.dart';
|
||||
export 'package:protevus_config/src/default_configurations.dart';
|
||||
export 'package:protevus_config/src/intermediate_exception.dart';
|
||||
export 'package:protevus_config/src/mirror_property.dart';
|
||||
export 'package:protevus_config/src/runtime.dart';
|
40
packages/config/lib/src/compiler.dart
Normal file
40
packages/config/lib/src/compiler.dart
Normal file
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:mirrors';
|
||||
|
||||
import 'package:protevus_config/config.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
class ConfigurationCompiler extends Compiler {
|
||||
@override
|
||||
Map<String, Object> compile(MirrorContext context) {
|
||||
return Map.fromEntries(
|
||||
context.getSubclassesOf(Configuration).map((c) {
|
||||
return MapEntry(
|
||||
MirrorSystem.getName(c.simpleName),
|
||||
ConfigurationRuntimeImpl(c),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void deflectPackage(Directory destinationDirectory) {
|
||||
final libFile = File.fromUri(
|
||||
destinationDirectory.uri.resolve("lib/").resolve("conduit_config.dart"),
|
||||
);
|
||||
final contents = libFile.readAsStringSync();
|
||||
libFile.writeAsStringSync(
|
||||
contents.replaceFirst(
|
||||
"export 'package:conduit_config/src/compiler.dart';", ""),
|
||||
);
|
||||
}
|
||||
}
|
271
packages/config/lib/src/configuration.dart
Normal file
271
packages/config/lib/src/configuration.dart
Normal file
|
@ -0,0 +1,271 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_config/config.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
/// Subclasses of [Configuration] read YAML strings and files, assigning values from the YAML document to properties
|
||||
/// of an instance of this type.
|
||||
abstract class Configuration {
|
||||
/// Default constructor.
|
||||
Configuration();
|
||||
|
||||
Configuration.fromMap(Map<dynamic, dynamic> map) {
|
||||
decode(map.map<String, dynamic>((k, v) => MapEntry(k.toString(), v)));
|
||||
}
|
||||
|
||||
/// [contents] must be YAML.
|
||||
Configuration.fromString(String contents) {
|
||||
final yamlMap = loadYaml(contents) as Map<dynamic, dynamic>?;
|
||||
final map =
|
||||
yamlMap?.map<String, dynamic>((k, v) => MapEntry(k.toString(), v));
|
||||
decode(map);
|
||||
}
|
||||
|
||||
/// Opens a file and reads its string contents into this instance's properties.
|
||||
///
|
||||
/// [file] must contain valid YAML data.
|
||||
Configuration.fromFile(File file) : this.fromString(file.readAsStringSync());
|
||||
|
||||
ConfigurationRuntime get _runtime =>
|
||||
RuntimeContext.current[runtimeType] as ConfigurationRuntime;
|
||||
|
||||
/// Ingests [value] into the properties of this type.
|
||||
///
|
||||
/// Override this method to provide decoding behavior other than the default behavior.
|
||||
void decode(dynamic value) {
|
||||
if (value is! Map) {
|
||||
throw ConfigurationException(
|
||||
this,
|
||||
"input is not an object (is a '${value.runtimeType}')",
|
||||
);
|
||||
}
|
||||
|
||||
_runtime.decode(this, value);
|
||||
|
||||
validate();
|
||||
}
|
||||
|
||||
/// Validates this configuration.
|
||||
///
|
||||
/// By default, ensures all required keys are non-null.
|
||||
///
|
||||
/// Override this method to perform validations on input data. Throw [ConfigurationException]
|
||||
/// for invalid data.
|
||||
@mustCallSuper
|
||||
void validate() {
|
||||
_runtime.validate(this);
|
||||
}
|
||||
|
||||
static dynamic getEnvironmentOrValue(dynamic value) {
|
||||
if (value is String && value.startsWith(r"$")) {
|
||||
final envKey = value.substring(1);
|
||||
if (!Platform.environment.containsKey(envKey)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Platform.environment[envKey];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ConfigurationRuntime {
|
||||
void decode(Configuration configuration, Map input);
|
||||
void validate(Configuration configuration);
|
||||
|
||||
dynamic tryDecode(
|
||||
Configuration configuration,
|
||||
String name,
|
||||
dynamic Function() decode,
|
||||
) {
|
||||
try {
|
||||
return decode();
|
||||
} on ConfigurationException catch (e) {
|
||||
throw ConfigurationException(
|
||||
configuration,
|
||||
e.message,
|
||||
keyPath: [name, ...e.keyPath],
|
||||
);
|
||||
} on IntermediateException catch (e) {
|
||||
final underlying = e.underlying;
|
||||
if (underlying is ConfigurationException) {
|
||||
final keyPaths = [
|
||||
[name],
|
||||
e.keyPath,
|
||||
underlying.keyPath,
|
||||
].expand((i) => i).toList();
|
||||
|
||||
throw ConfigurationException(
|
||||
configuration,
|
||||
underlying.message,
|
||||
keyPath: keyPaths,
|
||||
);
|
||||
} else if (underlying is TypeError) {
|
||||
throw ConfigurationException(
|
||||
configuration,
|
||||
"input is wrong type",
|
||||
keyPath: [name, ...e.keyPath],
|
||||
);
|
||||
}
|
||||
|
||||
throw ConfigurationException(
|
||||
configuration,
|
||||
underlying.toString(),
|
||||
keyPath: [name, ...e.keyPath],
|
||||
);
|
||||
} catch (e) {
|
||||
throw ConfigurationException(
|
||||
configuration,
|
||||
e.toString(),
|
||||
keyPath: [name],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Possible options for a configuration item property's optionality.
|
||||
enum ConfigurationItemAttributeType {
|
||||
/// [Configuration] properties marked as [required] will throw an exception
|
||||
/// if their source YAML doesn't contain a matching key.
|
||||
required,
|
||||
|
||||
/// [Configuration] properties marked as [optional] will be silently ignored
|
||||
/// if their source YAML doesn't contain a matching key.
|
||||
optional
|
||||
}
|
||||
|
||||
/// [Configuration] properties may be attributed with these.
|
||||
///
|
||||
/// **NOTICE**: This will be removed in version 2.0.0.
|
||||
/// To signify required or optional config you could do:
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// class MyConfig extends Config {
|
||||
/// late String required;
|
||||
/// String? optional;
|
||||
/// String optionalWithDefult = 'default';
|
||||
/// late String optionalWithComputedDefault = _default();
|
||||
///
|
||||
/// String _default() => 'computed';
|
||||
/// }
|
||||
/// ```
|
||||
class ConfigurationItemAttribute {
|
||||
const ConfigurationItemAttribute._(this.type);
|
||||
|
||||
final ConfigurationItemAttributeType type;
|
||||
}
|
||||
|
||||
/// A [ConfigurationItemAttribute] for required properties.
|
||||
///
|
||||
/// **NOTICE**: This will be removed in version 2.0.0.
|
||||
/// To signify required or optional config you could do:
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// class MyConfig extends Config {
|
||||
/// late String required;
|
||||
/// String? optional;
|
||||
/// String optionalWithDefult = 'default';
|
||||
/// late String optionalWithComputedDefault = _default();
|
||||
///
|
||||
/// String _default() => 'computed';
|
||||
/// }
|
||||
/// ```
|
||||
@Deprecated("Use `late` property")
|
||||
const ConfigurationItemAttribute requiredConfiguration =
|
||||
ConfigurationItemAttribute._(ConfigurationItemAttributeType.required);
|
||||
|
||||
/// A [ConfigurationItemAttribute] for optional properties.
|
||||
///
|
||||
/// **NOTICE**: This will be removed in version 2.0.0.
|
||||
/// To signify required or optional config you could do:
|
||||
/// Example:
|
||||
/// ```dart
|
||||
/// class MyConfig extends Config {
|
||||
/// late String required;
|
||||
/// String? optional;
|
||||
/// String optionalWithDefult = 'default';
|
||||
/// late String optionalWithComputedDefault = _default();
|
||||
///
|
||||
/// String _default() => 'computed';
|
||||
/// }
|
||||
/// ```
|
||||
@Deprecated("Use `nullable` property")
|
||||
const ConfigurationItemAttribute optionalConfiguration =
|
||||
ConfigurationItemAttribute._(ConfigurationItemAttributeType.optional);
|
||||
|
||||
/// Thrown when reading data into a [Configuration] fails.
|
||||
class ConfigurationException {
|
||||
ConfigurationException(
|
||||
this.configuration,
|
||||
this.message, {
|
||||
this.keyPath = const [],
|
||||
});
|
||||
|
||||
ConfigurationException.missingKeys(
|
||||
this.configuration,
|
||||
List<String> missingKeys, {
|
||||
this.keyPath = const [],
|
||||
}) : message =
|
||||
"missing required key(s): ${missingKeys.map((s) => "'$s'").join(", ")}";
|
||||
|
||||
/// The [Configuration] in which this exception occurred.
|
||||
final Configuration configuration;
|
||||
|
||||
/// The reason for the exception.
|
||||
final String message;
|
||||
|
||||
/// The key of the object being evaluated.
|
||||
///
|
||||
/// Either a string (adds '.name') or an int (adds '\[value\]').
|
||||
final List<dynamic> keyPath;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (keyPath.isEmpty) {
|
||||
return "Failed to read '${configuration.runtimeType}'\n\t-> $message";
|
||||
}
|
||||
final joinedKeyPath = StringBuffer();
|
||||
for (var i = 0; i < keyPath.length; i++) {
|
||||
final thisKey = keyPath[i];
|
||||
if (thisKey is String) {
|
||||
if (i != 0) {
|
||||
joinedKeyPath.write(".");
|
||||
}
|
||||
joinedKeyPath.write(thisKey);
|
||||
} else if (thisKey is int) {
|
||||
joinedKeyPath.write("[$thisKey]");
|
||||
} else {
|
||||
throw StateError("not an int or String");
|
||||
}
|
||||
}
|
||||
|
||||
return "Failed to read key '$joinedKeyPath' for '${configuration.runtimeType}'\n\t-> $message";
|
||||
}
|
||||
}
|
||||
|
||||
/// Thrown when [Configuration] subclass is invalid and requires a change in code.
|
||||
class ConfigurationError {
|
||||
ConfigurationError(this.type, this.message);
|
||||
|
||||
/// The type of [Configuration] in which this error appears in.
|
||||
final Type type;
|
||||
|
||||
/// The reason for the error.
|
||||
String message;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "Invalid configuration type '$type'. $message";
|
||||
}
|
||||
}
|
128
packages/config/lib/src/default_configurations.dart
Normal file
128
packages/config/lib/src/default_configurations.dart
Normal file
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import 'package:protevus_config/config.dart';
|
||||
|
||||
/// A [Configuration] to represent a database connection configuration.
|
||||
class DatabaseConfiguration extends Configuration {
|
||||
/// Default constructor.
|
||||
DatabaseConfiguration();
|
||||
|
||||
DatabaseConfiguration.fromFile(super.file) : super.fromFile();
|
||||
|
||||
DatabaseConfiguration.fromString(super.yaml) : super.fromString();
|
||||
|
||||
DatabaseConfiguration.fromMap(super.yaml) : super.fromMap();
|
||||
|
||||
/// A named constructor that contains all of the properties of this instance.
|
||||
DatabaseConfiguration.withConnectionInfo(
|
||||
this.username,
|
||||
this.password,
|
||||
this.host,
|
||||
this.port,
|
||||
this.databaseName, {
|
||||
this.isTemporary = false,
|
||||
});
|
||||
|
||||
/// The host of the database to connect to.
|
||||
///
|
||||
/// This property is required.
|
||||
late String host;
|
||||
|
||||
/// The port of the database to connect to.
|
||||
///
|
||||
/// This property is required.
|
||||
late int port;
|
||||
|
||||
/// The name of the database to connect to.
|
||||
///
|
||||
/// This property is required.
|
||||
late String databaseName;
|
||||
|
||||
/// A username for authenticating to the database.
|
||||
///
|
||||
/// This property is optional.
|
||||
String? username;
|
||||
|
||||
/// A password for authenticating to the database.
|
||||
///
|
||||
/// This property is optional.
|
||||
String? password;
|
||||
|
||||
/// A flag to represent permanence.
|
||||
///
|
||||
/// This flag is used for test suites that use a temporary database to run tests against,
|
||||
/// dropping it after the tests are complete.
|
||||
/// This property is optional.
|
||||
bool isTemporary = false;
|
||||
|
||||
@override
|
||||
void decode(dynamic value) {
|
||||
if (value is Map) {
|
||||
super.decode(value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value is! String) {
|
||||
throw ConfigurationException(
|
||||
this,
|
||||
"'${value.runtimeType}' is not assignable; must be a object or string",
|
||||
);
|
||||
}
|
||||
|
||||
final uri = Uri.parse(value);
|
||||
host = uri.host;
|
||||
port = uri.port;
|
||||
if (uri.pathSegments.length == 1) {
|
||||
databaseName = uri.pathSegments.first;
|
||||
}
|
||||
|
||||
if (uri.userInfo == '') {
|
||||
validate();
|
||||
return;
|
||||
}
|
||||
|
||||
final authority = uri.userInfo.split(":");
|
||||
if (authority.isNotEmpty) {
|
||||
username = Uri.decodeComponent(authority.first);
|
||||
}
|
||||
if (authority.length > 1) {
|
||||
password = Uri.decodeComponent(authority.last);
|
||||
}
|
||||
|
||||
validate();
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Configuration] to represent an external HTTP API.
|
||||
class APIConfiguration extends Configuration {
|
||||
APIConfiguration();
|
||||
|
||||
APIConfiguration.fromFile(super.file) : super.fromFile();
|
||||
|
||||
APIConfiguration.fromString(super.yaml) : super.fromString();
|
||||
|
||||
APIConfiguration.fromMap(super.yaml) : super.fromMap();
|
||||
|
||||
/// The base URL of the described API.
|
||||
///
|
||||
/// This property is required.
|
||||
/// Example: https://external.api.com:80/resources
|
||||
late String baseURL;
|
||||
|
||||
/// The client ID.
|
||||
///
|
||||
/// This property is optional.
|
||||
String? clientID;
|
||||
|
||||
/// The client secret.
|
||||
///
|
||||
/// This property is optional.
|
||||
String? clientSecret;
|
||||
}
|
16
packages/config/lib/src/intermediate_exception.dart
Normal file
16
packages/config/lib/src/intermediate_exception.dart
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
class IntermediateException implements Exception {
|
||||
IntermediateException(this.underlying, this.keyPath);
|
||||
|
||||
final dynamic underlying;
|
||||
|
||||
final List<dynamic> keyPath;
|
||||
}
|
249
packages/config/lib/src/mirror_property.dart
Normal file
249
packages/config/lib/src/mirror_property.dart
Normal file
|
@ -0,0 +1,249 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import 'dart:mirrors';
|
||||
|
||||
import 'package:protevus_config/config.dart';
|
||||
|
||||
class MirrorTypeCodec {
|
||||
MirrorTypeCodec(this.type) {
|
||||
if (type.isSubtypeOf(reflectType(Configuration))) {
|
||||
final klass = type as ClassMirror;
|
||||
final classHasDefaultConstructor = klass.declarations.values.any((dm) {
|
||||
return dm is MethodMirror &&
|
||||
dm.isConstructor &&
|
||||
dm.constructorName == Symbol.empty &&
|
||||
dm.parameters.every((p) => p.isOptional == true);
|
||||
});
|
||||
|
||||
if (!classHasDefaultConstructor) {
|
||||
throw StateError(
|
||||
"Failed to compile '${type.reflectedType}'\n\t-> "
|
||||
"'Configuration' subclasses MUST declare an unnammed constructor "
|
||||
"(i.e. '${type.reflectedType}();') if they are nested.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final TypeMirror type;
|
||||
|
||||
dynamic _decodeValue(dynamic value) {
|
||||
if (type.isSubtypeOf(reflectType(int))) {
|
||||
return _decodeInt(value);
|
||||
} else if (type.isSubtypeOf(reflectType(bool))) {
|
||||
return _decodeBool(value);
|
||||
} else if (type.isSubtypeOf(reflectType(Configuration))) {
|
||||
return _decodeConfig(value);
|
||||
} else if (type.isSubtypeOf(reflectType(List))) {
|
||||
return _decodeList(value as List);
|
||||
} else if (type.isSubtypeOf(reflectType(Map))) {
|
||||
return _decodeMap(value as Map);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
dynamic _decodeBool(dynamic value) {
|
||||
if (value is String) {
|
||||
return value == "true";
|
||||
}
|
||||
|
||||
return value as bool;
|
||||
}
|
||||
|
||||
dynamic _decodeInt(dynamic value) {
|
||||
if (value is String) {
|
||||
return int.parse(value);
|
||||
}
|
||||
|
||||
return value as int;
|
||||
}
|
||||
|
||||
Configuration _decodeConfig(dynamic object) {
|
||||
final item = (type as ClassMirror).newInstance(Symbol.empty, []).reflectee
|
||||
as Configuration;
|
||||
|
||||
item.decode(object);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
List _decodeList(List value) {
|
||||
final out = (type as ClassMirror).newInstance(const Symbol('empty'), [], {
|
||||
const Symbol('growable'): true,
|
||||
}).reflectee as List;
|
||||
final innerDecoder = MirrorTypeCodec(type.typeArguments.first);
|
||||
for (var i = 0; i < value.length; i++) {
|
||||
try {
|
||||
final v = innerDecoder._decodeValue(value[i]);
|
||||
out.add(v);
|
||||
} on IntermediateException catch (e) {
|
||||
e.keyPath.add(i);
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw IntermediateException(e, [i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Map<dynamic, dynamic> _decodeMap(Map value) {
|
||||
final map =
|
||||
(type as ClassMirror).newInstance(Symbol.empty, []).reflectee as Map;
|
||||
|
||||
final innerDecoder = MirrorTypeCodec(type.typeArguments.last);
|
||||
value.forEach((key, val) {
|
||||
if (key is! String) {
|
||||
throw StateError('cannot have non-String key');
|
||||
}
|
||||
|
||||
try {
|
||||
map[key] = innerDecoder._decodeValue(val);
|
||||
} on IntermediateException catch (e) {
|
||||
e.keyPath.add(key);
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw IntermediateException(e, [key]);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
String get expectedType {
|
||||
return type.reflectedType.toString();
|
||||
}
|
||||
|
||||
String get source {
|
||||
if (type.isSubtypeOf(reflectType(int))) {
|
||||
return _decodeIntSource;
|
||||
} else if (type.isSubtypeOf(reflectType(bool))) {
|
||||
return _decodeBoolSource;
|
||||
} else if (type.isSubtypeOf(reflectType(Configuration))) {
|
||||
return _decodeConfigSource;
|
||||
} else if (type.isSubtypeOf(reflectType(List))) {
|
||||
return _decodeListSource;
|
||||
} else if (type.isSubtypeOf(reflectType(Map))) {
|
||||
return _decodeMapSource;
|
||||
}
|
||||
|
||||
return "return v;";
|
||||
}
|
||||
|
||||
String get _decodeListSource {
|
||||
final typeParam = MirrorTypeCodec(type.typeArguments.first);
|
||||
return """
|
||||
final out = <${typeParam.expectedType}>[];
|
||||
final decoder = (v) {
|
||||
${typeParam.source}
|
||||
};
|
||||
for (var i = 0; i < (v as List).length; i++) {
|
||||
try {
|
||||
final innerValue = decoder(v[i]);
|
||||
out.add(innerValue);
|
||||
} on IntermediateException catch (e) {
|
||||
e.keyPath.add(i);
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw IntermediateException(e, [i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
""";
|
||||
}
|
||||
|
||||
String get _decodeMapSource {
|
||||
final typeParam = MirrorTypeCodec(type.typeArguments.last);
|
||||
return """
|
||||
final map = <String, ${typeParam.expectedType}>{};
|
||||
final decoder = (v) {
|
||||
${typeParam.source}
|
||||
};
|
||||
v.forEach((key, val) {
|
||||
if (key is! String) {
|
||||
throw StateError('cannot have non-String key');
|
||||
}
|
||||
|
||||
try {
|
||||
map[key] = decoder(val);
|
||||
} on IntermediateException catch (e) {
|
||||
e.keyPath.add(key);
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
throw IntermediateException(e, [key]);
|
||||
}
|
||||
});
|
||||
|
||||
return map;
|
||||
""";
|
||||
}
|
||||
|
||||
String get _decodeConfigSource {
|
||||
return """
|
||||
final item = $expectedType();
|
||||
|
||||
item.decode(v);
|
||||
|
||||
return item;
|
||||
""";
|
||||
}
|
||||
|
||||
String get _decodeIntSource {
|
||||
return """
|
||||
if (v is String) {
|
||||
return int.parse(v);
|
||||
}
|
||||
|
||||
return v as int;
|
||||
""";
|
||||
}
|
||||
|
||||
String get _decodeBoolSource {
|
||||
return """
|
||||
if (v is String) {
|
||||
return v == "true";
|
||||
}
|
||||
|
||||
return v as bool;
|
||||
""";
|
||||
}
|
||||
}
|
||||
|
||||
class MirrorConfigurationProperty {
|
||||
MirrorConfigurationProperty(this.property)
|
||||
: codec = MirrorTypeCodec(property.type);
|
||||
|
||||
final VariableMirror property;
|
||||
final MirrorTypeCodec codec;
|
||||
|
||||
String get key => MirrorSystem.getName(property.simpleName);
|
||||
bool get isRequired => _isVariableRequired(property);
|
||||
|
||||
String get source => codec.source;
|
||||
|
||||
static bool _isVariableRequired(VariableMirror m) {
|
||||
try {
|
||||
final attribute = m.metadata
|
||||
.firstWhere(
|
||||
(im) =>
|
||||
im.type.isSubtypeOf(reflectType(ConfigurationItemAttribute)),
|
||||
)
|
||||
.reflectee as ConfigurationItemAttribute;
|
||||
|
||||
return attribute.type == ConfigurationItemAttributeType.required;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
dynamic decode(dynamic input) {
|
||||
return codec._decodeValue(Configuration.getEnvironmentOrValue(input));
|
||||
}
|
||||
}
|
196
packages/config/lib/src/runtime.dart
Normal file
196
packages/config/lib/src/runtime.dart
Normal file
|
@ -0,0 +1,196 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import 'dart:mirrors';
|
||||
|
||||
import 'package:protevus_config/config.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
class ConfigurationRuntimeImpl extends ConfigurationRuntime
|
||||
implements SourceCompiler {
|
||||
ConfigurationRuntimeImpl(this.type) {
|
||||
// Should be done in the constructor so a type check could be run.
|
||||
properties = _collectProperties();
|
||||
}
|
||||
|
||||
final ClassMirror type;
|
||||
|
||||
late final Map<String, MirrorConfigurationProperty> properties;
|
||||
|
||||
@override
|
||||
void decode(Configuration configuration, Map input) {
|
||||
final values = Map.from(input);
|
||||
properties.forEach((name, property) {
|
||||
final takingValue = values.remove(name);
|
||||
if (takingValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final decodedValue = tryDecode(
|
||||
configuration,
|
||||
name,
|
||||
() => property.decode(takingValue),
|
||||
);
|
||||
if (decodedValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!reflect(decodedValue).type.isAssignableTo(property.property.type)) {
|
||||
throw ConfigurationException(
|
||||
configuration,
|
||||
"input is wrong type",
|
||||
keyPath: [name],
|
||||
);
|
||||
}
|
||||
|
||||
final mirror = reflect(configuration);
|
||||
mirror.setField(property.property.simpleName, decodedValue);
|
||||
});
|
||||
|
||||
if (values.isNotEmpty) {
|
||||
throw ConfigurationException(
|
||||
configuration,
|
||||
"unexpected keys found: ${values.keys.map((s) => "'$s'").join(", ")}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String get decodeImpl {
|
||||
final buf = StringBuffer();
|
||||
|
||||
buf.writeln("final valuesCopy = Map.from(input);");
|
||||
properties.forEach((k, v) {
|
||||
buf.writeln("{");
|
||||
buf.writeln(
|
||||
"final v = Configuration.getEnvironmentOrValue(valuesCopy.remove('$k'));",
|
||||
);
|
||||
buf.writeln("if (v != null) {");
|
||||
buf.writeln(
|
||||
" final decodedValue = tryDecode(configuration, '$k', () { ${v.source} });",
|
||||
);
|
||||
buf.writeln(" if (decodedValue is! ${v.codec.expectedType}) {");
|
||||
buf.writeln(
|
||||
" throw ConfigurationException(configuration, 'input is wrong type', keyPath: ['$k']);",
|
||||
);
|
||||
buf.writeln(" }");
|
||||
buf.writeln(
|
||||
" (configuration as ${type.reflectedType}).$k = decodedValue as ${v.codec.expectedType};",
|
||||
);
|
||||
buf.writeln("}");
|
||||
buf.writeln("}");
|
||||
});
|
||||
|
||||
buf.writeln(
|
||||
"""
|
||||
if (valuesCopy.isNotEmpty) {
|
||||
throw ConfigurationException(configuration,
|
||||
"unexpected keys found: \${valuesCopy.keys.map((s) => "'\$s'").join(", ")}.");
|
||||
}
|
||||
""",
|
||||
);
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
void validate(Configuration configuration) {
|
||||
final configMirror = reflect(configuration);
|
||||
final requiredValuesThatAreMissing = properties.values
|
||||
.where((v) {
|
||||
try {
|
||||
final value = configMirror.getField(Symbol(v.key)).reflectee;
|
||||
return v.isRequired && value == null;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
})
|
||||
.map((v) => v.key)
|
||||
.toList();
|
||||
|
||||
if (requiredValuesThatAreMissing.isNotEmpty) {
|
||||
throw ConfigurationException.missingKeys(
|
||||
configuration,
|
||||
requiredValuesThatAreMissing,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, MirrorConfigurationProperty> _collectProperties() {
|
||||
final declarations = <VariableMirror>[];
|
||||
|
||||
var ptr = type;
|
||||
while (ptr.isSubclassOf(reflectClass(Configuration))) {
|
||||
declarations.addAll(
|
||||
ptr.declarations.values
|
||||
.whereType<VariableMirror>()
|
||||
.where((vm) => !vm.isStatic && !vm.isPrivate),
|
||||
);
|
||||
ptr = ptr.superclass!;
|
||||
}
|
||||
|
||||
final m = <String, MirrorConfigurationProperty>{};
|
||||
for (final vm in declarations) {
|
||||
final name = MirrorSystem.getName(vm.simpleName);
|
||||
m[name] = MirrorConfigurationProperty(vm);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
String get validateImpl {
|
||||
final buf = StringBuffer();
|
||||
|
||||
const startValidation = """
|
||||
final missingKeys = <String>[];
|
||||
""";
|
||||
buf.writeln(startValidation);
|
||||
properties.forEach((name, property) {
|
||||
final propCheck = """
|
||||
try {
|
||||
final $name = (configuration as ${type.reflectedType}).$name;
|
||||
if (${property.isRequired} && $name == null) {
|
||||
missingKeys.add('$name');
|
||||
}
|
||||
} on Error catch (e) {
|
||||
missingKeys.add('$name');
|
||||
}""";
|
||||
buf.writeln(propCheck);
|
||||
});
|
||||
const throwIfErrors = """
|
||||
if (missingKeys.isNotEmpty) {
|
||||
throw ConfigurationException.missingKeys(configuration, missingKeys);
|
||||
}""";
|
||||
buf.writeln(throwIfErrors);
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> compile(BuildContext ctx) async {
|
||||
final directives = await ctx.getImportDirectives(
|
||||
uri: type.originalDeclaration.location!.sourceUri,
|
||||
alsoImportOriginalFile: true,
|
||||
)
|
||||
..add("import 'package:conduit_config/src/intermediate_exception.dart';");
|
||||
return """
|
||||
${directives.join("\n")}
|
||||
final instance = ConfigurationRuntimeImpl();
|
||||
class ConfigurationRuntimeImpl extends ConfigurationRuntime {
|
||||
@override
|
||||
void decode(Configuration configuration, Map input) {
|
||||
$decodeImpl
|
||||
}
|
||||
|
||||
@override
|
||||
void validate(Configuration configuration) {
|
||||
$validateImpl
|
||||
}
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
|
@ -10,7 +10,9 @@ environment:
|
|||
|
||||
# Add regular dependencies here.
|
||||
dependencies:
|
||||
# path: ^1.8.0
|
||||
protevus_runtime: ^0.0.1
|
||||
meta: ^1.3.0
|
||||
yaml: ^3.1.2
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^3.0.0
|
||||
|
|
4
packages/database/lib/db.dart
Normal file
4
packages/database/lib/db.dart
Normal file
|
@ -0,0 +1,4 @@
|
|||
export 'src/managed/managed.dart';
|
||||
export 'src/persistent_store/persistent_store.dart';
|
||||
export 'src/query/query.dart';
|
||||
export 'src/schema/schema.dart';
|
310
packages/database/lib/src/managed/attributes.dart
Normal file
310
packages/database/lib/src/managed/attributes.dart
Normal file
|
@ -0,0 +1,310 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:meta/meta_meta.dart';
|
||||
|
||||
/// Annotation to configure the table definition of a [ManagedObject].
|
||||
///
|
||||
/// Adding this metadata to a table definition (`T` in `ManagedObject<T>`) configures the behavior of the underlying table.
|
||||
/// For example:
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
///
|
||||
/// @Table(name: "_Account");
|
||||
/// class _User {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// String name;
|
||||
/// String email;
|
||||
/// }
|
||||
class Table {
|
||||
/// Default constructor.
|
||||
///
|
||||
/// If [name] is provided, the name of the underlying table will be its value. Otherwise,
|
||||
/// the name of the underlying table matches the name of the table definition class.
|
||||
///
|
||||
/// See also [Table.unique] for the behavior of [uniquePropertySet].
|
||||
const Table({
|
||||
this.useSnakeCaseName = false,
|
||||
this.name,
|
||||
this.uniquePropertySet,
|
||||
this.useSnakeCaseColumnName = false,
|
||||
});
|
||||
|
||||
/// Configures each instance of a table definition to be unique for the combination of [properties].
|
||||
///
|
||||
/// Adding this metadata to a table definition requires that all instances of this type
|
||||
/// must be unique for the combined properties in [properties]. [properties] must contain symbolic names of
|
||||
/// properties declared in the table definition, and those properties must be either attributes
|
||||
/// or belongs-to relationship properties. See [Table] for example.
|
||||
const Table.unique(List<Symbol> properties)
|
||||
: this(uniquePropertySet: properties);
|
||||
|
||||
/// Each instance of the associated table definition is unique for these properties.
|
||||
///
|
||||
/// null if not set.
|
||||
final List<Symbol>? uniquePropertySet;
|
||||
|
||||
/// Useful to indicate using new snake_case naming convention if [name] is not set
|
||||
/// This property defaults to false to avoid breaking change ensuring backward compatibility
|
||||
final bool useSnakeCaseName;
|
||||
|
||||
/// The name of the underlying database table.
|
||||
///
|
||||
/// If this value is not set, the name defaults to the name of the table definition class using snake_case naming convention without the prefix '_' underscore.
|
||||
final String? name;
|
||||
|
||||
/// Useful to indicate using new snake_case naming convention for columns.
|
||||
/// This property defaults to false to avoid breaking change ensuring backward compatibility
|
||||
///
|
||||
/// If a column is annotated with `@Column()` with a non-`null` value for
|
||||
/// `name` or `useSnakeCaseName`, that value takes precedent.
|
||||
final bool useSnakeCaseColumnName;
|
||||
}
|
||||
|
||||
/// Possible values for a delete rule in a [Relate].
|
||||
enum DeleteRule {
|
||||
/// Prevents a delete operation if the would-be deleted [ManagedObject] still has references to this relationship.
|
||||
restrict,
|
||||
|
||||
/// All objects with a foreign key reference to the deleted object will also be deleted.
|
||||
cascade,
|
||||
|
||||
/// All objects with a foreign key reference to the deleted object will have that reference nullified.
|
||||
nullify,
|
||||
|
||||
/// All objects with a foreign key reference to the deleted object will have that reference set to the column's default value.
|
||||
setDefault
|
||||
}
|
||||
|
||||
/// Metadata to configure property of [ManagedObject] as a foreign key column.
|
||||
///
|
||||
/// A property in a [ManagedObject]'s table definition with this metadata will map to a database column
|
||||
/// that has a foreign key reference to the related [ManagedObject]. Relationships are made up of two [ManagedObject]s, where each
|
||||
/// has a property that refers to the other. Only one of those properties may have this metadata. The property with this metadata
|
||||
/// resolves to a column in the database. The relationship property without this metadata resolves to a row or rows in the database.
|
||||
class Relate {
|
||||
/// Creates an instance of this type.
|
||||
const Relate(
|
||||
this.inversePropertyName, {
|
||||
this.onDelete = DeleteRule.nullify,
|
||||
this.isRequired = false,
|
||||
});
|
||||
|
||||
const Relate.deferred(DeleteRule onDelete, {bool isRequired = false})
|
||||
: this(_deferredSymbol, onDelete: onDelete, isRequired: isRequired);
|
||||
|
||||
/// The symbol for the property in the related [ManagedObject].
|
||||
///
|
||||
/// This value must be the symbol for the property in the related [ManagedObject]. This creates the link between
|
||||
/// two sides of a relationship between a [ManagedObject].
|
||||
final Symbol inversePropertyName;
|
||||
|
||||
/// The delete rule to use when a related instance is deleted.
|
||||
///
|
||||
/// This rule dictates how the database should handle deleting objects that have relationships. See [DeleteRule] for possible options.
|
||||
///
|
||||
/// If [isRequired] is true, this value may not be [DeleteRule.nullify]. This value defaults to [DeleteRule.nullify].
|
||||
final DeleteRule onDelete;
|
||||
|
||||
/// Whether or not this relationship is required.
|
||||
///
|
||||
/// By default, [Relate] properties are not required to support the default value of [onDelete].
|
||||
/// By setting this value to true, an instance of this entity cannot be created without a valid value for the relationship property.
|
||||
final bool isRequired;
|
||||
|
||||
bool get isDeferred => inversePropertyName == _deferredSymbol;
|
||||
|
||||
static const Symbol _deferredSymbol = #mdrDeferred;
|
||||
}
|
||||
|
||||
/// Metadata to describe the behavior of the underlying database column of a persistent property in [ManagedObject] subclasses.
|
||||
///
|
||||
/// By default, declaring a property in a table definition will make it a database column
|
||||
/// and its database column will be derived from the property's type.
|
||||
/// If the property needs additional directives - like indexing or uniqueness - it should be annotated with an instance of this class.
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// @Column(indexed: true, unique: true)
|
||||
/// String email;
|
||||
/// }
|
||||
class Column {
|
||||
/// Creates an instance of this type.
|
||||
///
|
||||
/// [defaultValue] is sent as-is to the database, therefore, if the default value is the integer value 2,
|
||||
/// pass the string "2". If the default value is a string, it must also be wrapped in single quotes: "'defaultValue'".
|
||||
const Column({
|
||||
this.databaseType,
|
||||
bool primaryKey = false,
|
||||
bool nullable = false,
|
||||
this.defaultValue,
|
||||
bool unique = false,
|
||||
bool indexed = false,
|
||||
bool omitByDefault = false,
|
||||
this.autoincrement = false,
|
||||
this.validators = const [],
|
||||
this.useSnakeCaseName,
|
||||
this.name,
|
||||
}) : isPrimaryKey = primaryKey,
|
||||
isNullable = nullable,
|
||||
isUnique = unique,
|
||||
isIndexed = indexed,
|
||||
shouldOmitByDefault = omitByDefault;
|
||||
|
||||
/// When true, indicates that this property is the primary key.
|
||||
///
|
||||
/// Only one property of a class may have primaryKey equal to true.
|
||||
final bool isPrimaryKey;
|
||||
|
||||
/// The type of the field in the database.
|
||||
///
|
||||
/// By default, the database column type is inferred from the Dart type of the property, e.g. a Dart [String] is a PostgreSQL text type.
|
||||
/// This allows you to override the default type mapping for the annotated property.
|
||||
final ManagedPropertyType? databaseType;
|
||||
|
||||
/// Indicates whether or not the property can be null or not.
|
||||
///
|
||||
/// By default, properties are not nullable.
|
||||
final bool isNullable;
|
||||
|
||||
/// The default value of the property.
|
||||
///
|
||||
/// By default, a property does not have a default property. This is a String to be interpreted by the database driver. For example,
|
||||
/// a PostgreSQL datetime column that defaults to the current time:
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User {
|
||||
/// @Column(defaultValue: "now()")
|
||||
/// DateTime createdDate;
|
||||
///
|
||||
/// ...
|
||||
/// }
|
||||
final String? defaultValue;
|
||||
|
||||
/// Whether or not the property is unique among all instances.
|
||||
///
|
||||
/// By default, properties are not unique.
|
||||
final bool isUnique;
|
||||
|
||||
/// Whether or not the backing database should generate an index for this property.
|
||||
///
|
||||
/// By default, properties are not indexed. Properties that are used often in database queries should be indexed.
|
||||
final bool isIndexed;
|
||||
|
||||
/// Whether or not fetching an instance of this type should include this property.
|
||||
///
|
||||
/// By default, all properties on a [ManagedObject] are returned if not specified (unless they are has-one or has-many relationship properties).
|
||||
/// This flag will remove the associated property from the result set unless it is explicitly specified by [Query.returningProperties].
|
||||
final bool shouldOmitByDefault;
|
||||
|
||||
/// A sequence generator will be used to generate the next value for this column when a row is inserted.
|
||||
///
|
||||
/// When this flag is true, the database will generate a value for this column on insert.
|
||||
final bool autoincrement;
|
||||
|
||||
/// A list of validators to apply to the annotated property.
|
||||
///
|
||||
/// Validators in this list will be applied to the annotated property.
|
||||
///
|
||||
/// When the data model is compiled, this list is combined with any `Validate` annotations on the annotated property.
|
||||
///
|
||||
final List<Validate> validators;
|
||||
|
||||
/// Useful to indicate using new snake_case naming convention if [name] is not set
|
||||
///
|
||||
/// This property defaults to null to delegate to [Table.useSnakeCaseColumnName]
|
||||
/// The default value, `null`, indicates that the behavior should be
|
||||
/// acquired from the [Table.useSnakeCaseColumnName] annotation on the
|
||||
/// enclosing class.
|
||||
final bool? useSnakeCaseName;
|
||||
|
||||
/// The name of the underlying column in table.
|
||||
///
|
||||
/// If this value is not set, the name defaults to the name of the model attribute using snake_case naming convention.
|
||||
final String? name;
|
||||
}
|
||||
|
||||
/// An annotation used to specify how a Model is serialized in API responses.
|
||||
@Target({TargetKind.classType})
|
||||
class ResponseModel {
|
||||
const ResponseModel({this.includeIfNullField = true});
|
||||
|
||||
/// Whether the serializer should include fields with `null` values in the
|
||||
/// serialized Model output.
|
||||
///
|
||||
/// If `true` (the default), all fields in the Model are written to JSON, even if they are
|
||||
/// `null`.
|
||||
///
|
||||
/// If a field is annotated with `@ResponseKey()` with a non-`null` value for
|
||||
/// `includeIfNull`, that value takes precedent.
|
||||
final bool includeIfNullField;
|
||||
}
|
||||
|
||||
/// An annotation used to specify how a field is serialized in API responses.
|
||||
@Target({TargetKind.field, TargetKind.getter, TargetKind.setter})
|
||||
class ResponseKey {
|
||||
const ResponseKey({this.name, this.includeIfNull});
|
||||
|
||||
/// The name to be used when serializing this field.
|
||||
///
|
||||
/// If this value is not set, the name defaults to [Column.name].
|
||||
final String? name;
|
||||
|
||||
/// Whether the serializer should include the field with `null` value in the
|
||||
/// serialized output.
|
||||
///
|
||||
/// If `true`, the serializer should include the field in the serialized
|
||||
/// output, even if the value is `null`.
|
||||
///
|
||||
/// The default value, `null`, indicates that the behavior should be
|
||||
/// acquired from the [ResponseModel.includeIfNullField] annotation on the
|
||||
/// enclosing class.
|
||||
final bool? includeIfNull;
|
||||
}
|
||||
|
||||
/// Annotation for [ManagedObject] properties that allows them to participate in [ManagedObject.asMap] and/or [ManagedObject.readFromMap].
|
||||
///
|
||||
/// See constructor.
|
||||
class Serialize {
|
||||
/// Annotates a [ManagedObject] property so it can be serialized.
|
||||
///
|
||||
/// A [ManagedObject] property declaration with this metadata will have its value encoded/decoded when
|
||||
/// converting the managed object to and from a [Map].
|
||||
///
|
||||
/// If [input] is true, this property's value is set when converting from a map.
|
||||
///
|
||||
/// If [output] is true, this property is in the map created by [ManagedObject.asMap].
|
||||
/// This key is only included if the value is non-null.
|
||||
///
|
||||
/// Both [input] and [output] default to true.
|
||||
const Serialize({bool input = true, bool output = true})
|
||||
: isAvailableAsInput = input,
|
||||
isAvailableAsOutput = output;
|
||||
|
||||
/// See constructor.
|
||||
final bool isAvailableAsInput;
|
||||
|
||||
/// See constructor.
|
||||
final bool isAvailableAsOutput;
|
||||
}
|
||||
|
||||
/// Primary key annotation for a ManagedObject table definition property.
|
||||
///
|
||||
/// This annotation is a convenience for the following annotation:
|
||||
///
|
||||
/// @Column(primaryKey: true, databaseType: ManagedPropertyType.bigInteger, autoincrement: true)
|
||||
/// int id;
|
||||
///
|
||||
/// The annotated property type must be [int].
|
||||
///
|
||||
/// The validator [Validate.constant] is automatically applied to a property with this annotation.
|
||||
const Column primaryKey = Column(
|
||||
primaryKey: true,
|
||||
databaseType: ManagedPropertyType.bigInteger,
|
||||
autoincrement: true,
|
||||
validators: [Validate.constant()],
|
||||
);
|
192
packages/database/lib/src/managed/backing.dart
Normal file
192
packages/database/lib/src/managed/backing.dart
Normal file
|
@ -0,0 +1,192 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
|
||||
final ArgumentError _invalidValueConstruction = ArgumentError(
|
||||
"Invalid property access when building 'Query.values'. "
|
||||
"May only assign values to properties backed by a column of the table being inserted into. "
|
||||
"This prohibits 'ManagedObject' and 'ManagedSet' properties, except for 'ManagedObject' "
|
||||
"properties with a 'Relate' annotation. For 'Relate' properties, you may only set their "
|
||||
"primary key property.");
|
||||
|
||||
class ManagedValueBacking extends ManagedBacking {
|
||||
@override
|
||||
Map<String, dynamic> contents = {};
|
||||
|
||||
@override
|
||||
dynamic valueForProperty(ManagedPropertyDescription property) {
|
||||
return contents[property.name];
|
||||
}
|
||||
|
||||
@override
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value) {
|
||||
if (value != null) {
|
||||
if (!property.isAssignableWith(value)) {
|
||||
throw ValidationException(
|
||||
["invalid input value for '${property.name}'"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
contents[property.name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedForeignKeyBuilderBacking extends ManagedBacking {
|
||||
ManagedForeignKeyBuilderBacking();
|
||||
ManagedForeignKeyBuilderBacking.from(
|
||||
ManagedEntity entity,
|
||||
ManagedBacking backing,
|
||||
) {
|
||||
if (backing.contents.containsKey(entity.primaryKey)) {
|
||||
contents[entity.primaryKey] = backing.contents[entity.primaryKey];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> contents = {};
|
||||
|
||||
@override
|
||||
dynamic valueForProperty(ManagedPropertyDescription property) {
|
||||
if (property is ManagedAttributeDescription && property.isPrimaryKey) {
|
||||
return contents[property.name];
|
||||
}
|
||||
|
||||
throw _invalidValueConstruction;
|
||||
}
|
||||
|
||||
@override
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value) {
|
||||
if (property is ManagedAttributeDescription && property.isPrimaryKey) {
|
||||
contents[property.name] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
throw _invalidValueConstruction;
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedBuilderBacking extends ManagedBacking {
|
||||
ManagedBuilderBacking();
|
||||
ManagedBuilderBacking.from(ManagedEntity entity, ManagedBacking original) {
|
||||
if (original is! ManagedValueBacking) {
|
||||
throw ArgumentError(
|
||||
"Invalid 'ManagedObject' assignment to 'Query.values'. Object must be created through default constructor.",
|
||||
);
|
||||
}
|
||||
|
||||
original.contents.forEach((key, value) {
|
||||
final prop = entity.properties[key];
|
||||
if (prop == null) {
|
||||
throw ArgumentError(
|
||||
"Invalid 'ManagedObject' assignment to 'Query.values'. Property '$key' does not exist for '${entity.name}'.",
|
||||
);
|
||||
}
|
||||
|
||||
if (prop is ManagedRelationshipDescription) {
|
||||
if (!prop.isBelongsTo) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setValueForProperty(prop, value);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> contents = {};
|
||||
|
||||
@override
|
||||
dynamic valueForProperty(ManagedPropertyDescription property) {
|
||||
if (property is ManagedRelationshipDescription) {
|
||||
if (!property.isBelongsTo) {
|
||||
throw _invalidValueConstruction;
|
||||
}
|
||||
|
||||
if (!contents.containsKey(property.name)) {
|
||||
contents[property.name] = property.inverse!.entity
|
||||
.instanceOf(backing: ManagedForeignKeyBuilderBacking());
|
||||
}
|
||||
}
|
||||
|
||||
return contents[property.name];
|
||||
}
|
||||
|
||||
@override
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value) {
|
||||
if (property is ManagedRelationshipDescription) {
|
||||
if (!property.isBelongsTo) {
|
||||
throw _invalidValueConstruction;
|
||||
}
|
||||
|
||||
if (value == null) {
|
||||
contents[property.name] = null;
|
||||
} else {
|
||||
final original = value as ManagedObject;
|
||||
final replacementBacking = ManagedForeignKeyBuilderBacking.from(
|
||||
original.entity,
|
||||
original.backing,
|
||||
);
|
||||
final replacement =
|
||||
original.entity.instanceOf(backing: replacementBacking);
|
||||
contents[property.name] = replacement;
|
||||
}
|
||||
} else {
|
||||
contents[property.name] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ManagedAccessTrackingBacking extends ManagedBacking {
|
||||
List<KeyPath> keyPaths = [];
|
||||
KeyPath? workingKeyPath;
|
||||
|
||||
@override
|
||||
Map<String, dynamic> get contents => {};
|
||||
|
||||
@override
|
||||
dynamic valueForProperty(ManagedPropertyDescription property) {
|
||||
if (workingKeyPath != null) {
|
||||
workingKeyPath!.add(property);
|
||||
|
||||
return forward(property, workingKeyPath);
|
||||
}
|
||||
|
||||
final keyPath = KeyPath(property);
|
||||
keyPaths.add(keyPath);
|
||||
|
||||
return forward(property, keyPath);
|
||||
}
|
||||
|
||||
@override
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
dynamic forward(ManagedPropertyDescription property, KeyPath? keyPath) {
|
||||
if (property is ManagedRelationshipDescription) {
|
||||
final tracker = ManagedAccessTrackingBacking()..workingKeyPath = keyPath;
|
||||
if (property.relationshipType == ManagedRelationshipType.hasMany) {
|
||||
return property.inverse!.entity.setOf([]);
|
||||
} else {
|
||||
return property.destinationEntity.instanceOf(backing: tracker);
|
||||
}
|
||||
} else if (property is ManagedAttributeDescription &&
|
||||
property.type!.kind == ManagedPropertyType.document) {
|
||||
return DocumentAccessTracker(keyPath);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentAccessTracker extends Document {
|
||||
DocumentAccessTracker(this.owner);
|
||||
|
||||
final KeyPath? owner;
|
||||
|
||||
@override
|
||||
dynamic operator [](dynamic keyOrIndex) {
|
||||
owner!.addDynamicElement(keyOrIndex);
|
||||
return this;
|
||||
}
|
||||
}
|
184
packages/database/lib/src/managed/context.dart
Normal file
184
packages/database/lib/src/managed/context.dart
Normal file
|
@ -0,0 +1,184 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/data_model_manager.dart' as mm;
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// A service object that handles connecting to and sending queries to a database.
|
||||
///
|
||||
/// You create objects of this type to use the Conduit ORM. Create instances in [ApplicationChannel.prepare]
|
||||
/// and inject them into controllers that execute database queries.
|
||||
///
|
||||
/// A context contains two types of objects:
|
||||
///
|
||||
/// - [PersistentStore] : Maintains a connection to a specific database. Transfers data between your application and the database.
|
||||
/// - [ManagedDataModel] : Contains information about the [ManagedObject] subclasses in your application.
|
||||
///
|
||||
/// Example usage:
|
||||
///
|
||||
/// class Channel extends ApplicationChannel {
|
||||
/// ManagedContext context;
|
||||
///
|
||||
/// @override
|
||||
/// Future prepare() async {
|
||||
/// var store = new PostgreSQLPersistentStore(...);
|
||||
/// var dataModel = new ManagedDataModel.fromCurrentMirrorSystem();
|
||||
/// context = new ManagedContext(dataModel, store);
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Controller get entryPoint {
|
||||
/// final router = new Router();
|
||||
/// router.route("/path").link(() => new DBController(context));
|
||||
/// return router;
|
||||
/// }
|
||||
/// }
|
||||
class ManagedContext implements APIComponentDocumenter {
|
||||
/// Creates an instance of [ManagedContext] from a [ManagedDataModel] and [PersistentStore].
|
||||
///
|
||||
/// This is the default constructor.
|
||||
///
|
||||
/// A [Query] is sent to the database described by [persistentStore]. A [Query] may only be executed
|
||||
/// on this context if its type is in [dataModel].
|
||||
ManagedContext(this.dataModel, this.persistentStore) {
|
||||
mm.add(dataModel!);
|
||||
_finalizer.attach(this, persistentStore, detach: this);
|
||||
}
|
||||
|
||||
/// Creates a child context from [parentContext].
|
||||
ManagedContext.childOf(ManagedContext parentContext)
|
||||
: persistentStore = parentContext.persistentStore,
|
||||
dataModel = parentContext.dataModel;
|
||||
|
||||
static final Finalizer<PersistentStore> _finalizer =
|
||||
Finalizer((store) async => store.close());
|
||||
|
||||
/// The persistent store that [Query]s on this context are executed through.
|
||||
PersistentStore persistentStore;
|
||||
|
||||
/// The data model containing the [ManagedEntity]s that describe the [ManagedObject]s this instance works with.
|
||||
final ManagedDataModel? dataModel;
|
||||
|
||||
/// Runs all [Query]s in [transactionBlock] within a database transaction.
|
||||
///
|
||||
/// Queries executed within [transactionBlock] will be executed as a database transaction.
|
||||
/// A [transactionBlock] is passed a [ManagedContext] that must be the target of all queries
|
||||
/// within the block. The context passed to the [transactionBlock] is *not* the same as
|
||||
/// the context the transaction was created from.
|
||||
///
|
||||
/// *You must not use the context this method was invoked on inside the transactionBlock.
|
||||
/// Doing so will deadlock your application.*
|
||||
///
|
||||
/// If an exception is encountered in [transactionBlock], any query that has already been
|
||||
/// executed will be rolled back and this method will rethrow the exception.
|
||||
///
|
||||
/// You may manually rollback a query by throwing a [Rollback] object. This will exit the
|
||||
/// [transactionBlock], roll back any changes made in the transaction, but this method will not
|
||||
/// throw.
|
||||
///
|
||||
/// TODO: the following statement is not true.
|
||||
/// Rollback takes a string but the transaction
|
||||
/// returns <T>. It would seem to be a better idea to still throw the manual Rollback
|
||||
/// so the user has a consistent method of handling the rollback. We could add a property
|
||||
/// to the Rollback class 'manual' which would be used to indicate a manual rollback.
|
||||
/// For the moment I've changed the return type to Future<void> as
|
||||
/// The parameter passed to [Rollback]'s constructor will be returned from this method
|
||||
/// so that the caller can determine why the transaction was rolled back.
|
||||
///
|
||||
/// Example usage:
|
||||
///
|
||||
/// await context.transaction((transaction) async {
|
||||
/// final q = new Query<Model>(transaction)
|
||||
/// ..values = someObject;
|
||||
/// await q.insert();
|
||||
/// ...
|
||||
/// });
|
||||
Future<T> transaction<T>(
|
||||
Future<T> Function(ManagedContext transaction) transactionBlock,
|
||||
) {
|
||||
return persistentStore.transaction(
|
||||
ManagedContext.childOf(this),
|
||||
transactionBlock,
|
||||
);
|
||||
}
|
||||
|
||||
/// Closes this context and release its underlying resources.
|
||||
///
|
||||
/// This method closes the connection to [persistentStore] and releases [dataModel].
|
||||
/// A context may not be reused once it has been closed.
|
||||
Future close() async {
|
||||
await persistentStore.close();
|
||||
_finalizer.detach(this);
|
||||
mm.remove(dataModel!);
|
||||
}
|
||||
|
||||
/// Returns an entity for a type from [dataModel].
|
||||
///
|
||||
/// See [ManagedDataModel.entityForType].
|
||||
ManagedEntity entityForType(Type type) {
|
||||
return dataModel!.entityForType(type);
|
||||
}
|
||||
|
||||
/// Inserts a single [object] into this context.
|
||||
///
|
||||
/// This method is equivalent shorthand for [Query.insert].
|
||||
Future<T> insertObject<T extends ManagedObject>(T object) {
|
||||
final query = Query<T>(this)..values = object;
|
||||
return query.insert();
|
||||
}
|
||||
|
||||
/// Inserts each object in [objects] into this context.
|
||||
///
|
||||
/// If any insertion fails, no objects will be inserted into the database and an exception
|
||||
/// is thrown.
|
||||
Future<List<T>> insertObjects<T extends ManagedObject>(
|
||||
List<T> objects,
|
||||
) async {
|
||||
return Query<T>(this).insertMany(objects);
|
||||
}
|
||||
|
||||
/// Returns an object of type [T] from this context if it exists, otherwise returns null.
|
||||
///
|
||||
/// If [T] cannot be inferred, an error is thrown. If [identifier] is not the same type as [T]'s primary key,
|
||||
/// null is returned.
|
||||
Future<T?> fetchObjectWithID<T extends ManagedObject>(
|
||||
dynamic identifier,
|
||||
) async {
|
||||
final entity = dataModel!.tryEntityForType(T);
|
||||
if (entity == null) {
|
||||
throw ArgumentError("Unknown entity '$T' in fetchObjectWithID. "
|
||||
"Provide a type to this method and ensure it is in this context's data model.");
|
||||
}
|
||||
|
||||
final primaryKey = entity.primaryKeyAttribute!;
|
||||
if (!primaryKey.type!.isAssignableWith(identifier)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final query = Query<T>(this)
|
||||
..where((o) => o[primaryKey.name]).equalTo(identifier);
|
||||
return query.fetchOne();
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) =>
|
||||
dataModel!.documentComponents(context);
|
||||
}
|
||||
|
||||
/// Throw this object to roll back a [ManagedContext.transaction].
|
||||
///
|
||||
/// When thrown in a transaction, it will cancel an in-progress transaction and rollback
|
||||
/// any changes it has made.
|
||||
class Rollback {
|
||||
/// Default constructor, takes a [reason] object that can be anything.
|
||||
///
|
||||
/// The parameter [reason] will be returned by [ManagedContext.transaction].
|
||||
Rollback(this.reason);
|
||||
|
||||
/// The reason this rollback occurred.
|
||||
///
|
||||
/// This value is returned from [ManagedContext.transaction] when this instance is thrown.
|
||||
final String reason;
|
||||
}
|
121
packages/database/lib/src/managed/data_model.dart
Normal file
121
packages/database/lib/src/managed/data_model.dart
Normal file
|
@ -0,0 +1,121 @@
|
|||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Instances of this class contain descriptions and metadata for mapping [ManagedObject]s to database rows.
|
||||
///
|
||||
/// An instance of this type must be used to initialize a [ManagedContext], and so are required to use [Query]s.
|
||||
///
|
||||
/// The [ManagedDataModel.fromCurrentMirrorSystem] constructor will reflect on an application's code and find
|
||||
/// all subclasses of [ManagedObject], building a [ManagedEntity] for each.
|
||||
///
|
||||
/// Most applications do not need to access instances of this type.
|
||||
///
|
||||
class ManagedDataModel extends Object implements APIComponentDocumenter {
|
||||
/// Creates an instance of [ManagedDataModel] from a list of types that extend [ManagedObject]. It is preferable
|
||||
/// to use [ManagedDataModel.fromCurrentMirrorSystem] over this method.
|
||||
///
|
||||
/// To register a class as a managed object within this data model, you must include its type in the list. Example:
|
||||
///
|
||||
/// new DataModel([User, Token, Post]);
|
||||
ManagedDataModel(List<Type> instanceTypes) {
|
||||
final runtimes = RuntimeContext.current.runtimes.iterable
|
||||
.whereType<ManagedEntityRuntime>()
|
||||
.toList();
|
||||
final expectedRuntimes = instanceTypes
|
||||
.map(
|
||||
(t) => runtimes.firstWhereOrNull((e) => e.entity.instanceType == t),
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (expectedRuntimes.any((e) => e == null)) {
|
||||
throw ManagedDataModelError(
|
||||
"Data model types were not found!",
|
||||
);
|
||||
}
|
||||
|
||||
for (final runtime in expectedRuntimes) {
|
||||
_entities[runtime!.entity.instanceType] = runtime.entity;
|
||||
_tableDefinitionToEntityMap[runtime.entity.tableDefinition] =
|
||||
runtime.entity;
|
||||
}
|
||||
for (final runtime in expectedRuntimes) {
|
||||
runtime!.finalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an instance of a [ManagedDataModel] from all subclasses of [ManagedObject] in all libraries visible to the calling library.
|
||||
///
|
||||
/// This constructor will search every available package and file library that is visible to the library
|
||||
/// that runs this constructor for subclasses of [ManagedObject]. A [ManagedEntity] will be created
|
||||
/// and stored in this instance for every such class found.
|
||||
///
|
||||
/// Standard Dart libraries (prefixed with 'dart:') and URL-encoded libraries (prefixed with 'data:') are not searched.
|
||||
///
|
||||
/// This is the preferred method of instantiating this type.
|
||||
ManagedDataModel.fromCurrentMirrorSystem() {
|
||||
final runtimes = RuntimeContext.current.runtimes.iterable
|
||||
.whereType<ManagedEntityRuntime>();
|
||||
|
||||
for (final runtime in runtimes) {
|
||||
_entities[runtime.entity.instanceType] = runtime.entity;
|
||||
_tableDefinitionToEntityMap[runtime.entity.tableDefinition] =
|
||||
runtime.entity;
|
||||
}
|
||||
for (final runtime in runtimes) {
|
||||
runtime.finalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
Iterable<ManagedEntity> get entities => _entities.values;
|
||||
final Map<Type, ManagedEntity> _entities = {};
|
||||
final Map<String, ManagedEntity> _tableDefinitionToEntityMap = {};
|
||||
|
||||
/// Returns a [ManagedEntity] for a [Type].
|
||||
///
|
||||
/// [type] may be either a subclass of [ManagedObject] or a [ManagedObject]'s table definition. For example, the following
|
||||
/// definition, you could retrieve its entity by passing MyModel or _MyModel as an argument to this method:
|
||||
///
|
||||
/// class MyModel extends ManagedObject<_MyModel> implements _MyModel {}
|
||||
/// class _MyModel {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
/// }
|
||||
/// If the [type] has no known [ManagedEntity] then a [StateError] is thrown.
|
||||
/// Use [tryEntityForType] to test if an entity exists.
|
||||
ManagedEntity entityForType(Type type) {
|
||||
final entity = tryEntityForType(type);
|
||||
|
||||
if (entity == null) {
|
||||
throw StateError(
|
||||
"No entity found for '$type. Did you forget to create a 'ManagedContext'?",
|
||||
);
|
||||
}
|
||||
|
||||
return entity;
|
||||
}
|
||||
|
||||
ManagedEntity? tryEntityForType(Type type) =>
|
||||
_entities[type] ?? _tableDefinitionToEntityMap[type.toString()];
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
for (final e in entities) {
|
||||
e.documentComponents(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thrown when a [ManagedDataModel] encounters an error.
|
||||
class ManagedDataModelError extends Error {
|
||||
ManagedDataModelError(this.message);
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "Data Model Error: $message";
|
||||
}
|
||||
}
|
37
packages/database/lib/src/managed/data_model_manager.dart
Normal file
37
packages/database/lib/src/managed/data_model_manager.dart
Normal file
|
@ -0,0 +1,37 @@
|
|||
import 'package:protevus_database/src/managed/data_model.dart';
|
||||
import 'package:protevus_database/src/managed/entity.dart';
|
||||
|
||||
Map<ManagedDataModel, int> _dataModels = {};
|
||||
|
||||
ManagedEntity findEntity(
|
||||
Type type, {
|
||||
ManagedEntity Function()? orElse,
|
||||
}) {
|
||||
for (final d in _dataModels.keys) {
|
||||
final entity = d.tryEntityForType(type);
|
||||
if (entity != null) {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
if (orElse == null) {
|
||||
throw StateError(
|
||||
"No entity found for '$type. Did you forget to create a 'ManagedContext'?",
|
||||
);
|
||||
}
|
||||
|
||||
return orElse();
|
||||
}
|
||||
|
||||
void add(ManagedDataModel dataModel) {
|
||||
_dataModels.update(dataModel, (count) => count + 1, ifAbsent: () => 1);
|
||||
}
|
||||
|
||||
void remove(ManagedDataModel dataModel) {
|
||||
if (_dataModels[dataModel] != null) {
|
||||
_dataModels.update(dataModel, (count) => count - 1);
|
||||
if (_dataModels[dataModel]! < 1) {
|
||||
_dataModels.remove(dataModel);
|
||||
}
|
||||
}
|
||||
}
|
57
packages/database/lib/src/managed/document.dart
Normal file
57
packages/database/lib/src/managed/document.dart
Normal file
|
@ -0,0 +1,57 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
/// Allows storage of unstructured data in a [ManagedObject] property.
|
||||
///
|
||||
/// [Document]s may be properties of [ManagedObject] table definition. They are a container
|
||||
/// for [data] that is a JSON-encodable [Map] or [List]. When storing a [Document] in a database column,
|
||||
/// [data] is JSON-encoded.
|
||||
///
|
||||
/// Use this type to store unstructured or 'schema-less' data. Example:
|
||||
///
|
||||
/// class Event extends ManagedObject<_Event> implements _Event {}
|
||||
/// class _Event {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// String type;
|
||||
///
|
||||
/// Document details;
|
||||
/// }
|
||||
class Document {
|
||||
/// Creates an instance with an optional initial [data].
|
||||
///
|
||||
/// If no argument is passed, [data] is null. Otherwise, it is the first argument.
|
||||
Document([this.data]);
|
||||
|
||||
/// The JSON-encodable data contained by this instance.
|
||||
///
|
||||
/// This value must be JSON-encodable.
|
||||
dynamic data;
|
||||
|
||||
/// Returns an element of [data] by index or key.
|
||||
///
|
||||
/// [keyOrIndex] may be a [String] or [int].
|
||||
///
|
||||
/// When [data] is a [Map], [keyOrIndex] must be a [String] and will return the object for the key
|
||||
/// in that map.
|
||||
///
|
||||
/// When [data] is a [List], [keyOrIndex] must be a [int] and will return the object at the index
|
||||
/// in that list.
|
||||
dynamic operator [](Object keyOrIndex) {
|
||||
return data[keyOrIndex];
|
||||
}
|
||||
|
||||
/// Sets an element of [data] by index or key.
|
||||
///
|
||||
/// [keyOrIndex] may be a [String] or [int]. [value] must be a JSON-encodable value.
|
||||
///
|
||||
/// When [data] is a [Map], [keyOrIndex] must be a [String] and will set [value] for the key
|
||||
/// [keyOrIndex].
|
||||
///
|
||||
/// When [data] is a [List], [keyOrIndex] must be a [int] and will set [value] for the index
|
||||
/// [keyOrIndex]. This index must be within the length of [data]. For all other [List] operations,
|
||||
/// you may cast [data] to [List].
|
||||
void operator []=(Object keyOrIndex, dynamic value) {
|
||||
data[keyOrIndex] = value;
|
||||
}
|
||||
}
|
382
packages/database/lib/src/managed/entity.dart
Normal file
382
packages/database/lib/src/managed/entity.dart
Normal file
|
@ -0,0 +1,382 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/backing.dart';
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Mapping information between a table in a database and a [ManagedObject] object.
|
||||
///
|
||||
/// An entity defines the mapping between a database table and [ManagedObject] subclass. Entities
|
||||
/// are created by declaring [ManagedObject] subclasses and instantiating a [ManagedDataModel].
|
||||
/// In general, you do not need to use or create instances of this class.
|
||||
///
|
||||
/// An entity describes the properties that a subclass of [ManagedObject] will have and their representation in the underlying database.
|
||||
/// Each of these properties are represented by an instance of a [ManagedPropertyDescription] subclass. A property is either an attribute or a relationship.
|
||||
///
|
||||
/// Attribute values are scalar (see [ManagedPropertyType]) - [int], [String], [DateTime], [double] and [bool].
|
||||
/// Attributes are typically backed by a column in the underlying database for a [ManagedObject], but may also represent transient values
|
||||
/// defined by the [instanceType].
|
||||
/// Attributes are represented by [ManagedAttributeDescription].
|
||||
///
|
||||
/// The value of a relationship property is a reference to another [ManagedObject]. If a relationship property has [Relate] metadata,
|
||||
/// the property is backed be a foreign key column in the underlying database. Relationships are represented by [ManagedRelationshipDescription].
|
||||
class ManagedEntity implements APIComponentDocumenter {
|
||||
/// Creates an instance of this type..
|
||||
///
|
||||
/// You should never call this method directly, it will be called by [ManagedDataModel].
|
||||
ManagedEntity(this._tableName, this.instanceType, this.tableDefinition);
|
||||
|
||||
/// The name of this entity.
|
||||
///
|
||||
/// This name will match the name of [instanceType].
|
||||
String get name => instanceType.toString();
|
||||
|
||||
/// The type of instances represented by this entity.
|
||||
///
|
||||
/// Managed objects are made up of two components, a table definition and an instance type. Applications
|
||||
/// use instances of the instance type to work with queries and data from the database table this entity represents.
|
||||
final Type instanceType;
|
||||
|
||||
/// Set of callbacks that are implemented differently depending on compilation target.
|
||||
///
|
||||
/// If running in default mode (mirrors enabled), is a set of mirror operations. Otherwise,
|
||||
/// code generated.
|
||||
ManagedEntityRuntime get runtime =>
|
||||
RuntimeContext.current[instanceType] as ManagedEntityRuntime;
|
||||
|
||||
/// The name of type of persistent instances represented by this entity.
|
||||
///
|
||||
/// Managed objects are made up of two components, a table definition and an instance type.
|
||||
/// The system uses this type to define the mapping to the underlying database table.
|
||||
final String tableDefinition;
|
||||
|
||||
/// All attribute values of this entity.
|
||||
///
|
||||
/// An attribute maps to a single column or field in a database that is a scalar value, such as a string, integer, etc. or a
|
||||
/// transient property declared in the instance type.
|
||||
/// The keys are the case-sensitive name of the attribute. Values that represent a relationship to another object
|
||||
/// are not stored in [attributes].
|
||||
late Map<String, ManagedAttributeDescription?> attributes;
|
||||
|
||||
/// All relationship values of this entity.
|
||||
///
|
||||
/// A relationship represents a value that is another [ManagedObject] or [ManagedSet] of [ManagedObject]s. Not all relationships
|
||||
/// correspond to a column or field in a database, only those with [Relate] metadata (see also [ManagedRelationshipType.belongsTo]). In
|
||||
/// this case, the underlying database column is a foreign key reference. The underlying database does not have storage
|
||||
/// for [ManagedRelationshipType.hasMany] or [ManagedRelationshipType.hasOne] properties, as those values are derived by the foreign key reference
|
||||
/// on the inverse relationship property.
|
||||
/// Keys are the case-sensitive name of the relationship.
|
||||
late Map<String, ManagedRelationshipDescription?> relationships;
|
||||
|
||||
/// All properties (relationships and attributes) of this entity.
|
||||
///
|
||||
/// The string key is the name of the property, case-sensitive. Values will be instances of either [ManagedAttributeDescription]
|
||||
/// or [ManagedRelationshipDescription]. This is the concatenation of [attributes] and [relationships].
|
||||
Map<String, ManagedPropertyDescription?> get properties {
|
||||
final all = Map<String, ManagedPropertyDescription?>.from(attributes);
|
||||
all.addAll(relationships);
|
||||
return all;
|
||||
}
|
||||
|
||||
/// Set of properties that, together, are unique for each instance of this entity.
|
||||
///
|
||||
/// If non-null, each instance of this entity is unique for the combination of values
|
||||
/// for these properties. Instances may have the same values for each property in [uniquePropertySet],
|
||||
/// but cannot have the same value for all properties in [uniquePropertySet]. This differs from setting
|
||||
/// a single property as unique with [Column], where each instance has
|
||||
/// a unique value for that property.
|
||||
///
|
||||
/// This value is set by adding [Table] to the table definition of a [ManagedObject].
|
||||
List<ManagedPropertyDescription>? uniquePropertySet;
|
||||
|
||||
/// List of [ManagedValidator]s for attributes of this entity.
|
||||
///
|
||||
/// All validators for all [attributes] in one, flat list. Order is undefined.
|
||||
late List<ManagedValidator> validators;
|
||||
|
||||
/// The list of default property names of this object.
|
||||
///
|
||||
/// By default, a [Query] will fetch the properties in this list. You may specify
|
||||
/// a different set of properties by setting the [Query.returningProperties] value. The default
|
||||
/// set of properties is a list of all attributes that do not have the [Column.shouldOmitByDefault] flag
|
||||
/// set in their [Column] and all [ManagedRelationshipType.belongsTo] relationships.
|
||||
///
|
||||
/// This list cannot be modified.
|
||||
List<String>? get defaultProperties {
|
||||
if (_defaultProperties == null) {
|
||||
final elements = <String?>[];
|
||||
elements.addAll(
|
||||
attributes.values
|
||||
.where((prop) => prop!.isIncludedInDefaultResultSet)
|
||||
.where((prop) => !prop!.isTransient)
|
||||
.map((prop) => prop!.name),
|
||||
);
|
||||
|
||||
elements.addAll(
|
||||
relationships.values
|
||||
.where(
|
||||
(prop) =>
|
||||
prop!.isIncludedInDefaultResultSet &&
|
||||
prop.relationshipType == ManagedRelationshipType.belongsTo,
|
||||
)
|
||||
.map((prop) => prop!.name),
|
||||
);
|
||||
_defaultProperties = List.unmodifiable(elements);
|
||||
}
|
||||
return _defaultProperties;
|
||||
}
|
||||
|
||||
/// Name of primary key property.
|
||||
///
|
||||
/// This is determined by the attribute with the [primaryKey] annotation.
|
||||
late String primaryKey;
|
||||
|
||||
ManagedAttributeDescription? get primaryKeyAttribute {
|
||||
return attributes[primaryKey];
|
||||
}
|
||||
|
||||
/// A map from accessor symbol name to property name.
|
||||
///
|
||||
/// This map should not be modified.
|
||||
late Map<Symbol, String> symbolMap;
|
||||
|
||||
/// Name of table in database this entity maps to.
|
||||
///
|
||||
/// By default, the table will be named by the table definition, e.g., a managed object declared as so will have a [tableName] of '_User'.
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User { ... }
|
||||
///
|
||||
/// You may implement the static method [tableName] on the table definition of a [ManagedObject] to return a [String] table
|
||||
/// name override this default.
|
||||
String get tableName {
|
||||
return _tableName;
|
||||
}
|
||||
|
||||
final String _tableName;
|
||||
List<String>? _defaultProperties;
|
||||
|
||||
/// Derived from this' [tableName].
|
||||
@override
|
||||
int get hashCode {
|
||||
return tableName.hashCode;
|
||||
}
|
||||
|
||||
/// Creates a new instance of this entity's instance type.
|
||||
///
|
||||
/// By default, the returned object will use a normal value backing map.
|
||||
/// If [backing] is non-null, it will be the backing map of the returned object.
|
||||
T instanceOf<T extends ManagedObject>({ManagedBacking? backing}) {
|
||||
if (backing != null) {
|
||||
return (runtime.instanceOfImplementation(backing: backing)..entity = this)
|
||||
as T;
|
||||
}
|
||||
return (runtime.instanceOfImplementation()..entity = this) as T;
|
||||
}
|
||||
|
||||
ManagedSet<T>? setOf<T extends ManagedObject>(Iterable<dynamic> objects) {
|
||||
return runtime.setOfImplementation(objects) as ManagedSet<T>?;
|
||||
}
|
||||
|
||||
/// Returns an attribute in this entity for a property selector.
|
||||
///
|
||||
/// Invokes [identifyProperties] with [propertyIdentifier], and ensures that a single attribute
|
||||
/// on this entity was selected. Returns that attribute.
|
||||
ManagedAttributeDescription identifyAttribute<T, U extends ManagedObject>(
|
||||
T Function(U x) propertyIdentifier,
|
||||
) {
|
||||
final keyPaths = identifyProperties(propertyIdentifier);
|
||||
if (keyPaths.length != 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot access more than one property for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final firstKeyPath = keyPaths.first;
|
||||
if (firstKeyPath.dynamicElements != null) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot access subdocuments for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final elements = firstKeyPath.path;
|
||||
if (elements.length > 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot use relationships for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final propertyName = elements.first!.name;
|
||||
final attribute = attributes[propertyName];
|
||||
if (attribute == null) {
|
||||
if (relationships.containsKey(propertyName)) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selection. Property '$propertyName' on "
|
||||
"'$name' "
|
||||
"is a relationship and cannot be selected for this operation.");
|
||||
} else {
|
||||
throw ArgumentError(
|
||||
"Invalid property selection. Column '$propertyName' does not "
|
||||
"exist on table '$tableName'.");
|
||||
}
|
||||
}
|
||||
|
||||
return attribute;
|
||||
}
|
||||
|
||||
/// Returns a relationship in this entity for a property selector.
|
||||
///
|
||||
/// Invokes [identifyProperties] with [propertyIdentifier], and ensures that a single relationship
|
||||
/// on this entity was selected. Returns that relationship.
|
||||
ManagedRelationshipDescription
|
||||
identifyRelationship<T, U extends ManagedObject>(
|
||||
T Function(U x) propertyIdentifier,
|
||||
) {
|
||||
final keyPaths = identifyProperties(propertyIdentifier);
|
||||
if (keyPaths.length != 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot access more than one property for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final firstKeyPath = keyPaths.first;
|
||||
if (firstKeyPath.dynamicElements != null) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot access subdocuments for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final elements = firstKeyPath.path;
|
||||
if (elements.length > 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot identify a nested relationship for this operation.",
|
||||
);
|
||||
}
|
||||
|
||||
final propertyName = elements.first!.name;
|
||||
final desc = relationships[propertyName];
|
||||
if (desc == null) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selection. Relationship named '$propertyName' on table '$tableName' is not a relationship.",
|
||||
);
|
||||
}
|
||||
|
||||
return desc;
|
||||
}
|
||||
|
||||
/// Returns a property selected by [propertyIdentifier].
|
||||
///
|
||||
/// Invokes [identifyProperties] with [propertyIdentifier], and ensures that a single property
|
||||
/// on this entity was selected. Returns that property.
|
||||
KeyPath identifyProperty<T, U extends ManagedObject>(
|
||||
T Function(U x) propertyIdentifier,
|
||||
) {
|
||||
final properties = identifyProperties(propertyIdentifier);
|
||||
if (properties.length != 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Must reference a single property only.",
|
||||
);
|
||||
}
|
||||
|
||||
return properties.first;
|
||||
}
|
||||
|
||||
/// Returns a list of properties selected by [propertiesIdentifier].
|
||||
///
|
||||
/// Each selected property in [propertiesIdentifier] is returned in a [KeyPath] object that fully identifies the
|
||||
/// property relative to this entity.
|
||||
List<KeyPath> identifyProperties<T, U extends ManagedObject>(
|
||||
T Function(U x) propertiesIdentifier,
|
||||
) {
|
||||
final tracker = ManagedAccessTrackingBacking();
|
||||
final obj = instanceOf<U>(backing: tracker);
|
||||
propertiesIdentifier(obj);
|
||||
|
||||
return tracker.keyPaths;
|
||||
}
|
||||
|
||||
APISchemaObject document(APIDocumentContext context) {
|
||||
final schemaProperties = <String, APISchemaObject>{};
|
||||
final obj = APISchemaObject.object(schemaProperties)..title = name;
|
||||
|
||||
final buffer = StringBuffer();
|
||||
if (uniquePropertySet != null) {
|
||||
final propString =
|
||||
uniquePropertySet!.map((s) => "'${s.name}'").join(", ");
|
||||
buffer.writeln(
|
||||
"No two objects may have the same value for all of: $propString.",
|
||||
);
|
||||
}
|
||||
|
||||
obj.description = buffer.toString();
|
||||
|
||||
properties.forEach((name, def) {
|
||||
if (def is ManagedAttributeDescription &&
|
||||
!def.isIncludedInDefaultResultSet &&
|
||||
!def.isTransient) {
|
||||
return;
|
||||
}
|
||||
|
||||
final schemaProperty = def!.documentSchemaObject(context);
|
||||
schemaProperties[name] = schemaProperty;
|
||||
});
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/// Two entities are considered equal if they have the same [tableName].
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is ManagedEntity && tableName == other.tableName;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final buf = StringBuffer();
|
||||
buf.writeln("Entity: $tableName");
|
||||
|
||||
buf.writeln("Attributes:");
|
||||
attributes.forEach((name, attr) {
|
||||
buf.writeln("\t$attr");
|
||||
});
|
||||
|
||||
buf.writeln("Relationships:");
|
||||
relationships.forEach((name, rel) {
|
||||
buf.writeln("\t$rel");
|
||||
});
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
final obj = document(context);
|
||||
context.schema.register(name, obj, representation: instanceType);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ManagedEntityRuntime {
|
||||
void finalize(ManagedDataModel dataModel) {}
|
||||
|
||||
ManagedEntity get entity;
|
||||
|
||||
ManagedObject instanceOfImplementation({ManagedBacking? backing});
|
||||
|
||||
ManagedSet setOfImplementation(Iterable<dynamic> objects);
|
||||
|
||||
void setTransientValueForKey(ManagedObject object, String key, dynamic value);
|
||||
|
||||
dynamic getTransientValueForKey(ManagedObject object, String? key);
|
||||
|
||||
bool isValueInstanceOf(dynamic value);
|
||||
|
||||
bool isValueListOf(dynamic value);
|
||||
|
||||
String? getPropertyName(Invocation invocation, ManagedEntity entity);
|
||||
|
||||
dynamic dynamicConvertFromPrimitiveValue(
|
||||
ManagedPropertyDescription property,
|
||||
dynamic value,
|
||||
);
|
||||
}
|
8
packages/database/lib/src/managed/exception.dart
Normal file
8
packages/database/lib/src/managed/exception.dart
Normal file
|
@ -0,0 +1,8 @@
|
|||
import 'package:protevus_http/src/serializable.dart';
|
||||
|
||||
/// An exception thrown when an ORM property validator is violated.
|
||||
///
|
||||
/// Behaves the same as [SerializableException].
|
||||
class ValidationException extends SerializableException {
|
||||
ValidationException(super.errors);
|
||||
}
|
27
packages/database/lib/src/managed/key_path.dart
Normal file
27
packages/database/lib/src/managed/key_path.dart
Normal file
|
@ -0,0 +1,27 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
class KeyPath {
|
||||
KeyPath(ManagedPropertyDescription? root) : path = [root];
|
||||
|
||||
KeyPath.byRemovingFirstNKeys(KeyPath original, int offset)
|
||||
: path = original.path.sublist(offset);
|
||||
|
||||
KeyPath.byAddingKey(KeyPath original, ManagedPropertyDescription key)
|
||||
: path = List.from(original.path)..add(key);
|
||||
|
||||
final List<ManagedPropertyDescription?> path;
|
||||
List<dynamic>? dynamicElements;
|
||||
|
||||
ManagedPropertyDescription? operator [](int index) => path[index];
|
||||
|
||||
int get length => path.length;
|
||||
|
||||
void add(ManagedPropertyDescription element) {
|
||||
path.add(element);
|
||||
}
|
||||
|
||||
void addDynamicElement(dynamic element) {
|
||||
dynamicElements ??= [];
|
||||
dynamicElements!.add(element);
|
||||
}
|
||||
}
|
13
packages/database/lib/src/managed/managed.dart
Normal file
13
packages/database/lib/src/managed/managed.dart
Normal file
|
@ -0,0 +1,13 @@
|
|||
export 'attributes.dart';
|
||||
export 'context.dart';
|
||||
export 'data_model.dart';
|
||||
export 'document.dart';
|
||||
export 'entity.dart';
|
||||
export 'exception.dart';
|
||||
export 'object.dart';
|
||||
export 'property_description.dart';
|
||||
export 'set.dart';
|
||||
export 'type.dart';
|
||||
export 'validation/managed.dart';
|
||||
export 'validation/metadata.dart';
|
||||
export 'key_path.dart';
|
304
packages/database/lib/src/managed/object.dart
Normal file
304
packages/database/lib/src/managed/object.dart
Normal file
|
@ -0,0 +1,304 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/backing.dart';
|
||||
import 'package:protevus_database/src/managed/data_model_manager.dart' as mm;
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_http/src/serializable.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// Instances of this class provide storage for [ManagedObject]s.
|
||||
///
|
||||
/// This class is primarily used internally.
|
||||
///
|
||||
/// A [ManagedObject] stores properties declared by its type argument in instances of this type.
|
||||
/// Values are validated against the [ManagedObject.entity].
|
||||
///
|
||||
/// Instances of this type only store properties for which a value has been explicitly set. This allows
|
||||
/// serialization classes to omit unset values from the serialized values. Therefore, instances of this class
|
||||
/// provide behavior that can differentiate between a property being the null value and a property simply not being
|
||||
/// set. (Therefore, you must use [removeProperty] instead of setting a value to null to really remove it from instances
|
||||
/// of this type.)
|
||||
///
|
||||
/// Conduit implements concrete subclasses of this class to provide behavior for property storage
|
||||
/// and query-building.
|
||||
abstract class ManagedBacking {
|
||||
/// Retrieve a property by its entity and name.
|
||||
dynamic valueForProperty(ManagedPropertyDescription property);
|
||||
|
||||
/// Sets a property by its entity and name.
|
||||
void setValueForProperty(ManagedPropertyDescription property, dynamic value);
|
||||
|
||||
/// Removes a property from this instance.
|
||||
///
|
||||
/// Use this method to use any reference of a property from this instance.
|
||||
void removeProperty(String propertyName) {
|
||||
contents.remove(propertyName);
|
||||
}
|
||||
|
||||
/// A map of all set values of this instance.
|
||||
Map<String, dynamic> get contents;
|
||||
}
|
||||
|
||||
/// An object that represents a database row.
|
||||
///
|
||||
/// This class must be subclassed. A subclass is declared for each table in a database. These subclasses
|
||||
/// create the data model of an application.
|
||||
///
|
||||
/// A managed object is declared in two parts, the subclass and its table definition.
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {
|
||||
/// String name;
|
||||
/// }
|
||||
/// class _User {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// @Column(indexed: true)
|
||||
/// String email;
|
||||
/// }
|
||||
///
|
||||
/// Table definitions are plain Dart objects that represent a database table. Each property is a column in the database.
|
||||
///
|
||||
/// A subclass of this type must implement its table definition and use it as the type argument of [ManagedObject]. Properties and methods
|
||||
/// declared in the subclass (also called the 'instance type') are not stored in the database.
|
||||
///
|
||||
/// See more documentation on defining a data model at http://conduit.io/docs/db/modeling_data/
|
||||
abstract class ManagedObject<T> extends Serializable {
|
||||
/// IMPROVEMENT: Cache of entity.properties to reduce property loading time
|
||||
late Map<String, ManagedPropertyDescription?> properties = entity.properties;
|
||||
|
||||
/// Cache of entity.properties using ResponseKey name as key, in case no ResponseKey is set then default property name is used as key
|
||||
late Map<String, ManagedPropertyDescription?> responseKeyProperties = {
|
||||
for (final key in properties.keys) mapKeyName(key): properties[key]
|
||||
};
|
||||
|
||||
late final bool modelFieldIncludeIfNull = properties.isEmpty ||
|
||||
(properties.values.first?.responseModel?.includeIfNullField ?? true);
|
||||
|
||||
String mapKeyName(String propertyName) {
|
||||
final property = properties[propertyName];
|
||||
return property?.responseKey?.name ?? property?.name ?? propertyName;
|
||||
}
|
||||
|
||||
static bool get shouldAutomaticallyDocument => false;
|
||||
|
||||
/// The [ManagedEntity] this instance is described by.
|
||||
ManagedEntity entity = mm.findEntity(T);
|
||||
|
||||
/// The persistent values of this object.
|
||||
///
|
||||
/// Values stored by this object are stored in [backing]. A backing is a [Map], where each key
|
||||
/// is a property name of this object. A backing adds some access logic to storing and retrieving
|
||||
/// its key-value pairs.
|
||||
///
|
||||
/// You rarely need to use [backing] directly. There are many implementations of [ManagedBacking]
|
||||
/// for fulfilling the behavior of the ORM, so you cannot rely on its behavior.
|
||||
ManagedBacking backing = ManagedValueBacking();
|
||||
|
||||
/// Retrieves a value by property name from [backing].
|
||||
dynamic operator [](String propertyName) {
|
||||
final prop = properties[propertyName];
|
||||
if (prop == null) {
|
||||
throw ArgumentError("Invalid property access for '${entity.name}'. "
|
||||
"Property '$propertyName' does not exist on '${entity.name}'.");
|
||||
}
|
||||
|
||||
return backing.valueForProperty(prop);
|
||||
}
|
||||
|
||||
/// Sets a value by property name in [backing].
|
||||
void operator []=(String? propertyName, dynamic value) {
|
||||
final prop = properties[propertyName];
|
||||
if (prop == null) {
|
||||
throw ArgumentError("Invalid property access for '${entity.name}'. "
|
||||
"Property '$propertyName' does not exist on '${entity.name}'.");
|
||||
}
|
||||
|
||||
backing.setValueForProperty(prop, value);
|
||||
}
|
||||
|
||||
/// Removes a property from [backing].
|
||||
///
|
||||
/// This will remove a value from the backing map.
|
||||
void removePropertyFromBackingMap(String propertyName) {
|
||||
backing.removeProperty(propertyName);
|
||||
}
|
||||
|
||||
/// Removes multiple properties from [backing].
|
||||
void removePropertiesFromBackingMap(List<String> propertyNames) {
|
||||
for (final propertyName in propertyNames) {
|
||||
backing.removeProperty(propertyName);
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks whether or not a property has been set in this instances' [backing].
|
||||
bool hasValueForProperty(String propertyName) {
|
||||
return backing.contents.containsKey(propertyName);
|
||||
}
|
||||
|
||||
/// Callback to modify an object prior to updating it with a [Query].
|
||||
///
|
||||
/// Subclasses of this type may override this method to set or modify values prior to being updated
|
||||
/// via [Query.update] or [Query.updateOne]. It is automatically invoked by [Query.update] and [Query.updateOne].
|
||||
///
|
||||
/// This method is invoked prior to validation and therefore any values modified in this method
|
||||
/// are subject to the validation behavior of this instance.
|
||||
///
|
||||
/// An example implementation would set the 'updatedDate' of an object each time it was updated:
|
||||
///
|
||||
/// @override
|
||||
/// void willUpdate() {
|
||||
/// updatedDate = new DateTime.now().toUtc();
|
||||
/// }
|
||||
///
|
||||
/// This method is only invoked when a query is configured by its [Query.values]. This method is not invoked
|
||||
/// if [Query.valueMap] is used to configure a query.
|
||||
void willUpdate() {}
|
||||
|
||||
/// Callback to modify an object prior to inserting it with a [Query].
|
||||
///
|
||||
/// Subclasses of this type may override this method to set or modify values prior to being inserted
|
||||
/// via [Query.insert]. It is automatically invoked by [Query.insert].
|
||||
///
|
||||
/// This method is invoked prior to validation and therefore any values modified in this method
|
||||
/// are subject to the validation behavior of this instance.
|
||||
///
|
||||
/// An example implementation would set the 'createdDate' of an object when it is first created
|
||||
///
|
||||
/// @override
|
||||
/// void willInsert() {
|
||||
/// createdDate = new DateTime.now().toUtc();
|
||||
/// }
|
||||
///
|
||||
/// This method is only invoked when a query is configured by its [Query.values]. This method is not invoked
|
||||
/// if [Query.valueMap] is used to configure a query.
|
||||
void willInsert() {}
|
||||
|
||||
/// Validates an object according to its property [Validate] metadata.
|
||||
///
|
||||
/// This method is invoked by [Query] when inserting or updating an instance of this type. By default,
|
||||
/// this method runs all of the [Validate] metadata for each property of this instance's persistent type. See [Validate]
|
||||
/// for more information. If validations succeed, the returned context [ValidationContext.isValid] will be true. Otherwise,
|
||||
/// it is false and all errors are available in [ValidationContext.errors].
|
||||
///
|
||||
/// This method returns the result of [ManagedValidator.run]. You may override this method to provide additional validation
|
||||
/// prior to insertion or deletion. If you override this method, you *must* invoke the super implementation to
|
||||
/// allow [Validate] annotations to run, e.g.:
|
||||
///
|
||||
/// ValidationContext validate({Validating forEvent: Validating.insert}) {
|
||||
/// var context = super(forEvent: forEvent);
|
||||
///
|
||||
/// if (a + b > 10) {
|
||||
/// context.addError("a + b > 10");
|
||||
/// }
|
||||
///
|
||||
/// return context;
|
||||
/// }
|
||||
@mustCallSuper
|
||||
ValidationContext validate({Validating forEvent = Validating.insert}) {
|
||||
return ManagedValidator.run(this, event: forEvent);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) {
|
||||
final propertyName = entity.runtime.getPropertyName(invocation, entity);
|
||||
if (propertyName != null) {
|
||||
if (invocation.isGetter) {
|
||||
return this[propertyName];
|
||||
} else if (invocation.isSetter) {
|
||||
this[propertyName] = invocation.positionalArguments.first;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
throw NoSuchMethodError.withInvocation(this, invocation);
|
||||
}
|
||||
|
||||
@override
|
||||
void readFromMap(Map<String, dynamic> object) {
|
||||
object.forEach((key, v) {
|
||||
final property = responseKeyProperties[key];
|
||||
if (property == null) {
|
||||
throw ValidationException(["invalid input key '$key'"]);
|
||||
}
|
||||
if (property.isPrivate) {
|
||||
throw ValidationException(["invalid input key '$key'"]);
|
||||
}
|
||||
|
||||
if (property is ManagedAttributeDescription) {
|
||||
if (!property.isTransient) {
|
||||
backing.setValueForProperty(
|
||||
property,
|
||||
property.convertFromPrimitiveValue(v),
|
||||
);
|
||||
} else {
|
||||
if (!property.transientStatus!.isAvailableAsInput) {
|
||||
throw ValidationException(["invalid input key '$key'"]);
|
||||
}
|
||||
|
||||
final decodedValue = property.convertFromPrimitiveValue(v);
|
||||
|
||||
if (!property.isAssignableWith(decodedValue)) {
|
||||
throw ValidationException(["invalid input type for key '$key'"]);
|
||||
}
|
||||
|
||||
entity.runtime
|
||||
.setTransientValueForKey(this, property.name, decodedValue);
|
||||
}
|
||||
} else {
|
||||
backing.setValueForProperty(
|
||||
property,
|
||||
property.convertFromPrimitiveValue(v),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Converts this instance into a serializable map.
|
||||
///
|
||||
/// This method returns a map of the key-values pairs in this instance. This value is typically converted into a transmission format like JSON.
|
||||
///
|
||||
/// Only properties present in [backing] are serialized, otherwise, they are omitted from the map. If a property is present in [backing] and the value is null,
|
||||
/// the value null will be serialized for that property key.
|
||||
///
|
||||
/// Usage:
|
||||
/// var json = json.encode(model.asMap());
|
||||
@override
|
||||
Map<String, dynamic> asMap() {
|
||||
final outputMap = <String, dynamic>{};
|
||||
|
||||
backing.contents.forEach((k, v) {
|
||||
if (!_isPropertyPrivate(k)) {
|
||||
final property = properties[k];
|
||||
final value = property!.convertToPrimitiveValue(v);
|
||||
if (value == null && !_includeIfNull(property)) {
|
||||
return;
|
||||
}
|
||||
outputMap[mapKeyName(k)] = value;
|
||||
}
|
||||
});
|
||||
|
||||
entity.attributes.values
|
||||
.where((attr) => attr!.transientStatus?.isAvailableAsOutput ?? false)
|
||||
.forEach((attr) {
|
||||
final value = entity.runtime.getTransientValueForKey(this, attr!.name);
|
||||
if (value != null) {
|
||||
outputMap[mapKeyName(attr.responseKey?.name ?? attr.name)] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return outputMap;
|
||||
}
|
||||
|
||||
@override
|
||||
APISchemaObject documentSchema(APIDocumentContext context) =>
|
||||
entity.document(context);
|
||||
|
||||
static bool _isPropertyPrivate(String propertyName) =>
|
||||
propertyName.startsWith("_");
|
||||
|
||||
bool _includeIfNull(ManagedPropertyDescription property) =>
|
||||
property.responseKey?.includeIfNull ?? modelFieldIncludeIfNull;
|
||||
}
|
582
packages/database/lib/src/managed/property_description.dart
Normal file
582
packages/database/lib/src/managed/property_description.dart
Normal file
|
@ -0,0 +1,582 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Contains database column information and metadata for a property of a [ManagedObject] object.
|
||||
///
|
||||
/// Each property a [ManagedObject] object manages is described by an instance of [ManagedPropertyDescription], which contains useful information
|
||||
/// about the property such as its name and type. Those properties are represented by concrete subclasses of this class, [ManagedRelationshipDescription]
|
||||
/// and [ManagedAttributeDescription].
|
||||
abstract class ManagedPropertyDescription {
|
||||
ManagedPropertyDescription(
|
||||
this.entity,
|
||||
this.name,
|
||||
this.type,
|
||||
this.declaredType, {
|
||||
bool unique = false,
|
||||
bool indexed = false,
|
||||
bool nullable = false,
|
||||
bool includedInDefaultResultSet = true,
|
||||
this.autoincrement = false,
|
||||
List<ManagedValidator> validators = const [],
|
||||
this.responseModel,
|
||||
this.responseKey,
|
||||
}) : isUnique = unique,
|
||||
isIndexed = indexed,
|
||||
isNullable = nullable,
|
||||
isIncludedInDefaultResultSet = includedInDefaultResultSet,
|
||||
_validators = validators {
|
||||
for (final v in _validators) {
|
||||
v.property = this;
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to the [ManagedEntity] that contains this property.
|
||||
final ManagedEntity entity;
|
||||
|
||||
/// The value type of this property.
|
||||
///
|
||||
/// Will indicate the Dart type and database column type of this property.
|
||||
final ManagedType? type;
|
||||
|
||||
/// The identifying name of this property.
|
||||
final String name;
|
||||
|
||||
/// Whether or not this property must be unique to across all instances represented by [entity].
|
||||
///
|
||||
/// Defaults to false.
|
||||
final bool isUnique;
|
||||
|
||||
/// Whether or not this property should be indexed by a [PersistentStore].
|
||||
///
|
||||
/// Defaults to false.
|
||||
final bool isIndexed;
|
||||
|
||||
/// Whether or not this property can be null.
|
||||
///
|
||||
/// Defaults to false.
|
||||
final bool isNullable;
|
||||
|
||||
/// Whether or not this property is returned in the default set of [Query.returningProperties].
|
||||
///
|
||||
/// This defaults to true. If true, when executing a [Query] that does not explicitly specify [Query.returningProperties],
|
||||
/// this property will be returned. If false, you must explicitly specify this property in [Query.returningProperties] to retrieve it from persistent storage.
|
||||
final bool isIncludedInDefaultResultSet;
|
||||
|
||||
/// Whether or not this property should use an auto-incrementing scheme.
|
||||
///
|
||||
/// By default, false. When true, it signals to the [PersistentStore] that this property should automatically be assigned a value
|
||||
/// by the database.
|
||||
final bool autoincrement;
|
||||
|
||||
/// Whether or not this attribute is private or not.
|
||||
///
|
||||
/// Private variables are prefixed with `_` (underscores). This properties are not read
|
||||
/// or written to maps and cannot be accessed from outside the class.
|
||||
///
|
||||
/// This flag is not included in schemas documents used by database migrations and other tools.
|
||||
bool get isPrivate {
|
||||
return name.startsWith("_");
|
||||
}
|
||||
|
||||
/// [ManagedValidator]s for this instance.
|
||||
List<ManagedValidator> get validators => _validators;
|
||||
|
||||
final List<ManagedValidator> _validators;
|
||||
|
||||
final ResponseModel? responseModel;
|
||||
final ResponseKey? responseKey;
|
||||
|
||||
/// Whether or not a the argument can be assigned to this property.
|
||||
bool isAssignableWith(dynamic dartValue) => type!.isAssignableWith(dartValue);
|
||||
|
||||
/// Converts a value from a more complex value into a primitive value according to this instance's definition.
|
||||
///
|
||||
/// This method takes a Dart representation of a value and converts it to something that can
|
||||
/// be used elsewhere (e.g. an HTTP body or database query). How this value is computed
|
||||
/// depends on this instance's definition.
|
||||
dynamic convertToPrimitiveValue(dynamic value);
|
||||
|
||||
/// Converts a value to a more complex value from a primitive value according to this instance's definition.
|
||||
///
|
||||
/// This method takes a non-Dart representation of a value (e.g. an HTTP body or database query)
|
||||
/// and turns it into a Dart representation . How this value is computed
|
||||
/// depends on this instance's definition.
|
||||
dynamic convertFromPrimitiveValue(dynamic value);
|
||||
|
||||
/// The type of the variable that this property represents.
|
||||
final Type? declaredType;
|
||||
|
||||
/// Returns an [APISchemaObject] that represents this property.
|
||||
///
|
||||
/// Used during documentation.
|
||||
APISchemaObject documentSchemaObject(APIDocumentContext context);
|
||||
|
||||
static APISchemaObject _typedSchemaObject(ManagedType type) {
|
||||
switch (type.kind) {
|
||||
case ManagedPropertyType.integer:
|
||||
return APISchemaObject.integer();
|
||||
case ManagedPropertyType.bigInteger:
|
||||
return APISchemaObject.integer();
|
||||
case ManagedPropertyType.doublePrecision:
|
||||
return APISchemaObject.number();
|
||||
case ManagedPropertyType.string:
|
||||
return APISchemaObject.string();
|
||||
case ManagedPropertyType.datetime:
|
||||
return APISchemaObject.string(format: "date-time");
|
||||
case ManagedPropertyType.boolean:
|
||||
return APISchemaObject.boolean();
|
||||
case ManagedPropertyType.list:
|
||||
return APISchemaObject.array(
|
||||
ofSchema: _typedSchemaObject(type.elements!),
|
||||
);
|
||||
case ManagedPropertyType.map:
|
||||
return APISchemaObject.map(
|
||||
ofSchema: _typedSchemaObject(type.elements!),
|
||||
);
|
||||
case ManagedPropertyType.document:
|
||||
return APISchemaObject.freeForm();
|
||||
}
|
||||
|
||||
// throw UnsupportedError("Unsupported type '$type' when documenting entity.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores the specifics of database columns in [ManagedObject]s as indicated by [Column].
|
||||
///
|
||||
/// This class is used internally to manage data models. For specifying these attributes,
|
||||
/// see [Column].
|
||||
///
|
||||
/// Attributes are the scalar values of a [ManagedObject] (as opposed to relationship values,
|
||||
/// which are [ManagedRelationshipDescription] instances).
|
||||
///
|
||||
/// Each scalar property [ManagedObject] object persists is described by an instance of [ManagedAttributeDescription]. This class
|
||||
/// adds two properties to [ManagedPropertyDescription] that are only valid for non-relationship types, [isPrimaryKey] and [defaultValue].
|
||||
class ManagedAttributeDescription extends ManagedPropertyDescription {
|
||||
ManagedAttributeDescription(
|
||||
super.entity,
|
||||
super.name,
|
||||
ManagedType super.type,
|
||||
super.declaredType, {
|
||||
this.transientStatus,
|
||||
bool primaryKey = false,
|
||||
this.defaultValue,
|
||||
super.unique,
|
||||
super.indexed,
|
||||
super.nullable,
|
||||
super.includedInDefaultResultSet,
|
||||
super.autoincrement,
|
||||
super.validators,
|
||||
super.responseModel,
|
||||
super.responseKey,
|
||||
}) : isPrimaryKey = primaryKey;
|
||||
|
||||
ManagedAttributeDescription.transient(
|
||||
super.entity,
|
||||
super.name,
|
||||
ManagedType super.type,
|
||||
Type super.declaredType,
|
||||
this.transientStatus, {
|
||||
super.responseKey,
|
||||
}) : isPrimaryKey = false,
|
||||
defaultValue = null,
|
||||
super(
|
||||
unique: false,
|
||||
indexed: false,
|
||||
nullable: false,
|
||||
includedInDefaultResultSet: false,
|
||||
autoincrement: false,
|
||||
validators: [],
|
||||
);
|
||||
|
||||
static ManagedAttributeDescription make<T>(
|
||||
ManagedEntity entity,
|
||||
String name,
|
||||
ManagedType type, {
|
||||
Serialize? transientStatus,
|
||||
bool primaryKey = false,
|
||||
String? defaultValue,
|
||||
bool unique = false,
|
||||
bool indexed = false,
|
||||
bool nullable = false,
|
||||
bool includedInDefaultResultSet = true,
|
||||
bool autoincrement = false,
|
||||
List<ManagedValidator> validators = const [],
|
||||
ResponseKey? responseKey,
|
||||
ResponseModel? responseModel,
|
||||
}) {
|
||||
return ManagedAttributeDescription(
|
||||
entity,
|
||||
name,
|
||||
type,
|
||||
T,
|
||||
transientStatus: transientStatus,
|
||||
primaryKey: primaryKey,
|
||||
defaultValue: defaultValue,
|
||||
unique: unique,
|
||||
indexed: indexed,
|
||||
nullable: nullable,
|
||||
includedInDefaultResultSet: includedInDefaultResultSet,
|
||||
autoincrement: autoincrement,
|
||||
validators: validators,
|
||||
responseKey: responseKey,
|
||||
responseModel: responseModel,
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether or not this attribute is the primary key for its [ManagedEntity].
|
||||
///
|
||||
/// Defaults to false.
|
||||
final bool isPrimaryKey;
|
||||
|
||||
/// The default value for this attribute.
|
||||
///
|
||||
/// By default, null. This value is a String, so the underlying persistent store is responsible for parsing it. This allows for default values
|
||||
/// that aren't constant values, such as database function calls.
|
||||
final String? defaultValue;
|
||||
|
||||
/// Whether or not this attribute is backed directly by the database.
|
||||
///
|
||||
/// If [transientStatus] is non-null, this value will be true. Otherwise, the attribute is backed by a database field/column.
|
||||
bool get isTransient => transientStatus != null;
|
||||
|
||||
/// Contains lookup table for string value of an enumeration to the enumerated value.
|
||||
///
|
||||
/// Value is null when this attribute does not represent an enumerated type.
|
||||
///
|
||||
/// If `enum Options { option1, option2 }` then this map contains:
|
||||
///
|
||||
/// {
|
||||
/// "option1": Options.option1,
|
||||
/// "option2": Options.option2
|
||||
/// }
|
||||
///
|
||||
Map<String, dynamic> get enumerationValueMap => type!.enumerationMap;
|
||||
|
||||
/// The validity of a transient attribute as input, output or both.
|
||||
///
|
||||
/// If this property is non-null, the attribute is transient (not backed by a database field/column).
|
||||
final Serialize? transientStatus;
|
||||
|
||||
/// Whether or not this attribute is represented by a Dart enum.
|
||||
bool get isEnumeratedValue => enumerationValueMap.isNotEmpty;
|
||||
|
||||
@override
|
||||
APISchemaObject documentSchemaObject(APIDocumentContext context) {
|
||||
final prop = ManagedPropertyDescription._typedSchemaObject(type!)
|
||||
..title = name;
|
||||
final buf = StringBuffer();
|
||||
|
||||
// Add'l schema info
|
||||
prop.isNullable = isNullable;
|
||||
for (final v in validators) {
|
||||
v.definition.constrainSchemaObject(context, prop);
|
||||
}
|
||||
|
||||
if (isEnumeratedValue) {
|
||||
prop.enumerated = prop.enumerated!.map(convertToPrimitiveValue).toList();
|
||||
}
|
||||
|
||||
if (isTransient) {
|
||||
if (transientStatus!.isAvailableAsInput &&
|
||||
!transientStatus!.isAvailableAsOutput) {
|
||||
prop.isWriteOnly = true;
|
||||
} else if (!transientStatus!.isAvailableAsInput &&
|
||||
transientStatus!.isAvailableAsOutput) {
|
||||
prop.isReadOnly = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isUnique) {
|
||||
buf.writeln("No two objects may have the same value for this field.");
|
||||
}
|
||||
|
||||
if (isPrimaryKey) {
|
||||
buf.writeln("This is the primary identifier for this object.");
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
prop.defaultValue = defaultValue;
|
||||
}
|
||||
|
||||
if (buf.isNotEmpty) {
|
||||
prop.description = buf.toString();
|
||||
}
|
||||
|
||||
return prop;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final flagBuffer = StringBuffer();
|
||||
if (isPrimaryKey) {
|
||||
flagBuffer.write("primary_key ");
|
||||
}
|
||||
if (isTransient) {
|
||||
flagBuffer.write("transient ");
|
||||
}
|
||||
if (autoincrement) {
|
||||
flagBuffer.write("autoincrementing ");
|
||||
}
|
||||
if (isUnique) {
|
||||
flagBuffer.write("unique ");
|
||||
}
|
||||
if (defaultValue != null) {
|
||||
flagBuffer.write("defaults to $defaultValue ");
|
||||
}
|
||||
if (isIndexed) {
|
||||
flagBuffer.write("indexed ");
|
||||
}
|
||||
if (isNullable) {
|
||||
flagBuffer.write("nullable ");
|
||||
} else {
|
||||
flagBuffer.write("required ");
|
||||
}
|
||||
|
||||
return "- $name | $type | Flags: $flagBuffer";
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic convertToPrimitiveValue(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type!.kind == ManagedPropertyType.datetime && value is DateTime) {
|
||||
return value.toIso8601String();
|
||||
} else if (isEnumeratedValue) {
|
||||
// todo: optimize?
|
||||
return value.toString().split(".").last;
|
||||
} else if (type!.kind == ManagedPropertyType.document &&
|
||||
value is Document) {
|
||||
return value.data;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic convertFromPrimitiveValue(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type!.kind == ManagedPropertyType.datetime) {
|
||||
if (value is! String) {
|
||||
throw ValidationException(["invalid input value for '$name'"]);
|
||||
}
|
||||
return DateTime.parse(value);
|
||||
} else if (type!.kind == ManagedPropertyType.doublePrecision) {
|
||||
if (value is! num) {
|
||||
throw ValidationException(["invalid input value for '$name'"]);
|
||||
}
|
||||
return value.toDouble();
|
||||
} else if (isEnumeratedValue) {
|
||||
if (!enumerationValueMap.containsKey(value)) {
|
||||
throw ValidationException(["invalid option for key '$name'"]);
|
||||
}
|
||||
return enumerationValueMap[value];
|
||||
} else if (type!.kind == ManagedPropertyType.document) {
|
||||
return Document(value);
|
||||
} else if (type!.kind == ManagedPropertyType.list ||
|
||||
type!.kind == ManagedPropertyType.map) {
|
||||
try {
|
||||
return entity.runtime.dynamicConvertFromPrimitiveValue(this, value);
|
||||
} on TypeCoercionException {
|
||||
throw ValidationException(["invalid input value for '$name'"]);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/// Contains information for a relationship property of a [ManagedObject].
|
||||
class ManagedRelationshipDescription extends ManagedPropertyDescription {
|
||||
ManagedRelationshipDescription(
|
||||
super.entity,
|
||||
super.name,
|
||||
super.type,
|
||||
super.declaredType,
|
||||
this.destinationEntity,
|
||||
this.deleteRule,
|
||||
this.relationshipType,
|
||||
this.inverseKey, {
|
||||
super.unique,
|
||||
super.indexed,
|
||||
super.nullable,
|
||||
super.includedInDefaultResultSet,
|
||||
super.validators = const [],
|
||||
super.responseModel,
|
||||
super.responseKey,
|
||||
});
|
||||
|
||||
static ManagedRelationshipDescription make<T>(
|
||||
ManagedEntity entity,
|
||||
String name,
|
||||
ManagedType? type,
|
||||
ManagedEntity destinationEntity,
|
||||
DeleteRule? deleteRule,
|
||||
ManagedRelationshipType relationshipType,
|
||||
String inverseKey, {
|
||||
bool unique = false,
|
||||
bool indexed = false,
|
||||
bool nullable = false,
|
||||
bool includedInDefaultResultSet = true,
|
||||
List<ManagedValidator> validators = const [],
|
||||
ResponseKey? responseKey,
|
||||
ResponseModel? responseModel,
|
||||
}) {
|
||||
return ManagedRelationshipDescription(
|
||||
entity,
|
||||
name,
|
||||
type,
|
||||
T,
|
||||
destinationEntity,
|
||||
deleteRule,
|
||||
relationshipType,
|
||||
inverseKey,
|
||||
unique: unique,
|
||||
indexed: indexed,
|
||||
nullable: nullable,
|
||||
includedInDefaultResultSet: includedInDefaultResultSet,
|
||||
validators: validators,
|
||||
responseKey: responseKey,
|
||||
responseModel: responseModel,
|
||||
);
|
||||
}
|
||||
|
||||
/// The entity that this relationship's instances are represented by.
|
||||
final ManagedEntity destinationEntity;
|
||||
|
||||
/// The delete rule for this relationship.
|
||||
final DeleteRule? deleteRule;
|
||||
|
||||
/// The type of relationship.
|
||||
final ManagedRelationshipType relationshipType;
|
||||
|
||||
/// The name of the [ManagedRelationshipDescription] on [destinationEntity] that represents the inverse of this relationship.
|
||||
final String inverseKey;
|
||||
|
||||
/// The [ManagedRelationshipDescription] on [destinationEntity] that represents the inverse of this relationship.
|
||||
ManagedRelationshipDescription? get inverse =>
|
||||
destinationEntity.relationships[inverseKey];
|
||||
|
||||
/// Whether or not this relationship is on the belonging side.
|
||||
bool get isBelongsTo => relationshipType == ManagedRelationshipType.belongsTo;
|
||||
|
||||
/// Whether or not a the argument can be assigned to this property.
|
||||
@override
|
||||
bool isAssignableWith(dynamic dartValue) {
|
||||
if (relationshipType == ManagedRelationshipType.hasMany) {
|
||||
return destinationEntity.runtime.isValueListOf(dartValue);
|
||||
}
|
||||
return destinationEntity.runtime.isValueInstanceOf(dartValue);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic convertToPrimitiveValue(dynamic value) {
|
||||
if (value is ManagedSet) {
|
||||
return value
|
||||
.map((ManagedObject innerValue) => innerValue.asMap())
|
||||
.toList();
|
||||
} else if (value is ManagedObject) {
|
||||
// If we're only fetching the foreign key, don't do a full asMap
|
||||
if (relationshipType == ManagedRelationshipType.belongsTo &&
|
||||
value.backing.contents.length == 1 &&
|
||||
value.backing.contents.containsKey(destinationEntity.primaryKey)) {
|
||||
return <String, Object>{
|
||||
destinationEntity.primaryKey: value[destinationEntity.primaryKey]
|
||||
};
|
||||
}
|
||||
|
||||
return value.asMap();
|
||||
} else if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw StateError(
|
||||
"Invalid relationship assigment. Relationship '$entity.$name' is not a 'ManagedSet' or 'ManagedObject'.",
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic convertFromPrimitiveValue(dynamic value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (relationshipType == ManagedRelationshipType.belongsTo ||
|
||||
relationshipType == ManagedRelationshipType.hasOne) {
|
||||
if (value is! Map<String, dynamic>) {
|
||||
throw ValidationException(["invalid input type for '$name'"]);
|
||||
}
|
||||
|
||||
final instance = destinationEntity.instanceOf()..readFromMap(value);
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
/* else if (relationshipType == ManagedRelationshipType.hasMany) { */
|
||||
|
||||
if (value is! List) {
|
||||
throw ValidationException(["invalid input type for '$name'"]);
|
||||
}
|
||||
|
||||
ManagedObject instantiator(dynamic m) {
|
||||
if (m is! Map<String, dynamic>) {
|
||||
throw ValidationException(["invalid input type for '$name'"]);
|
||||
}
|
||||
final instance = destinationEntity.instanceOf()..readFromMap(m);
|
||||
return instance;
|
||||
}
|
||||
|
||||
return destinationEntity.setOf(value.map(instantiator));
|
||||
}
|
||||
|
||||
@override
|
||||
APISchemaObject documentSchemaObject(APIDocumentContext context) {
|
||||
final relatedType =
|
||||
context.schema.getObjectWithType(inverse!.entity.instanceType);
|
||||
|
||||
if (relationshipType == ManagedRelationshipType.hasMany) {
|
||||
return APISchemaObject.array(ofSchema: relatedType)
|
||||
..isReadOnly = true
|
||||
..isNullable = true;
|
||||
} else if (relationshipType == ManagedRelationshipType.hasOne) {
|
||||
return relatedType
|
||||
..isReadOnly = true
|
||||
..isNullable = true;
|
||||
}
|
||||
|
||||
final destPk = destinationEntity.primaryKeyAttribute!;
|
||||
return APISchemaObject.object({
|
||||
destPk.name: ManagedPropertyDescription._typedSchemaObject(destPk.type!)
|
||||
})
|
||||
..title = name;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
var relTypeString = "has-one";
|
||||
switch (relationshipType) {
|
||||
case ManagedRelationshipType.belongsTo:
|
||||
relTypeString = "belongs to";
|
||||
break;
|
||||
case ManagedRelationshipType.hasMany:
|
||||
relTypeString = "has-many";
|
||||
break;
|
||||
case ManagedRelationshipType.hasOne:
|
||||
relTypeString = "has-a";
|
||||
break;
|
||||
// case null:
|
||||
// relTypeString = 'Not set';
|
||||
// break;
|
||||
}
|
||||
return "- $name -> '${destinationEntity.name}' | Type: $relTypeString | Inverse: $inverseKey";
|
||||
}
|
||||
}
|
2
packages/database/lib/src/managed/relationship_type.dart
Normal file
2
packages/database/lib/src/managed/relationship_type.dart
Normal file
|
@ -0,0 +1,2 @@
|
|||
/// The possible database relationships.
|
||||
enum ManagedRelationshipType { hasOne, hasMany, belongsTo }
|
71
packages/database/lib/src/managed/set.dart
Normal file
71
packages/database/lib/src/managed/set.dart
Normal file
|
@ -0,0 +1,71 @@
|
|||
import 'dart:collection';
|
||||
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
/// Instances of this type contain zero or more instances of [ManagedObject] and represent has-many relationships.
|
||||
///
|
||||
/// 'Has many' relationship properties in [ManagedObject]s are represented by this type. [ManagedSet]s properties may only be declared in the persistent
|
||||
/// type of a [ManagedObject]. Example usage:
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User {
|
||||
/// ...
|
||||
/// ManagedSet<Post> posts;
|
||||
/// }
|
||||
///
|
||||
/// class Post extends ManagedObject<_Post> implements _Post {}
|
||||
/// class _Post {
|
||||
/// ...
|
||||
/// @Relate(#posts)
|
||||
/// User user;
|
||||
/// }
|
||||
class ManagedSet<InstanceType extends ManagedObject> extends Object
|
||||
with ListMixin<InstanceType> {
|
||||
/// Creates an empty [ManagedSet].
|
||||
ManagedSet() {
|
||||
_innerValues = [];
|
||||
}
|
||||
|
||||
/// Creates a [ManagedSet] from an [Iterable] of [InstanceType]s.
|
||||
ManagedSet.from(Iterable<InstanceType> items) {
|
||||
_innerValues = items.toList();
|
||||
}
|
||||
|
||||
/// Creates a [ManagedSet] from an [Iterable] of [dynamic]s.
|
||||
ManagedSet.fromDynamic(Iterable<dynamic> items) {
|
||||
_innerValues = List<InstanceType>.from(items);
|
||||
}
|
||||
|
||||
late final List<InstanceType> _innerValues;
|
||||
|
||||
/// The number of elements in this set.
|
||||
@override
|
||||
int get length => _innerValues.length;
|
||||
|
||||
@override
|
||||
set length(int newLength) {
|
||||
_innerValues.length = newLength;
|
||||
}
|
||||
|
||||
/// Adds an [InstanceType] to this set.
|
||||
@override
|
||||
void add(InstanceType item) {
|
||||
_innerValues.add(item);
|
||||
}
|
||||
|
||||
/// Adds an [Iterable] of [InstanceType] to this set.
|
||||
@override
|
||||
void addAll(Iterable<InstanceType> items) {
|
||||
_innerValues.addAll(items);
|
||||
}
|
||||
|
||||
/// Retrieves an [InstanceType] from this set by an index.
|
||||
@override
|
||||
InstanceType operator [](int index) => _innerValues[index];
|
||||
|
||||
/// Set an [InstanceType] in this set by an index.
|
||||
@override
|
||||
void operator []=(int index, InstanceType value) {
|
||||
_innerValues[index] = value;
|
||||
}
|
||||
}
|
128
packages/database/lib/src/managed/type.dart
Normal file
128
packages/database/lib/src/managed/type.dart
Normal file
|
@ -0,0 +1,128 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
/// Possible data types for [ManagedEntity] attributes.
|
||||
enum ManagedPropertyType {
|
||||
/// Represented by instances of [int].
|
||||
integer,
|
||||
|
||||
/// Represented by instances of [int].
|
||||
bigInteger,
|
||||
|
||||
/// Represented by instances of [String].
|
||||
string,
|
||||
|
||||
/// Represented by instances of [DateTime].
|
||||
datetime,
|
||||
|
||||
/// Represented by instances of [bool].
|
||||
boolean,
|
||||
|
||||
/// Represented by instances of [double].
|
||||
doublePrecision,
|
||||
|
||||
/// Represented by instances of [Map].
|
||||
map,
|
||||
|
||||
/// Represented by instances of [List].
|
||||
list,
|
||||
|
||||
/// Represented by instances of [Document]
|
||||
document
|
||||
}
|
||||
|
||||
/// Complex type storage for [ManagedEntity] attributes.
|
||||
class ManagedType {
|
||||
/// Creates a new instance.
|
||||
///
|
||||
/// [type] must be representable by [ManagedPropertyType].
|
||||
ManagedType(this.type, this.kind, this.elements, this.enumerationMap);
|
||||
|
||||
static ManagedType make<T>(
|
||||
ManagedPropertyType kind,
|
||||
ManagedType? elements,
|
||||
Map<String, dynamic> enumerationMap,
|
||||
) {
|
||||
return ManagedType(T, kind, elements, enumerationMap);
|
||||
}
|
||||
|
||||
/// The primitive kind of this type.
|
||||
///
|
||||
/// All types have a kind. If kind is a map or list, it will also have [elements].
|
||||
final ManagedPropertyType kind;
|
||||
|
||||
/// The primitive kind of each element of this type.
|
||||
///
|
||||
/// If [kind] is a collection (map or list), this value stores the type of each element in the collection.
|
||||
/// Keys of map types are always [String].
|
||||
final ManagedType? elements;
|
||||
|
||||
/// Dart representation of this type.
|
||||
final Type type;
|
||||
|
||||
/// Whether this is an enum type.
|
||||
bool get isEnumerated => enumerationMap.isNotEmpty;
|
||||
|
||||
/// For enumerated types, this is a map of the name of the option to its Dart enum type.
|
||||
final Map<String, dynamic> enumerationMap;
|
||||
|
||||
/// Whether [dartValue] can be assigned to properties with this type.
|
||||
bool isAssignableWith(dynamic dartValue) {
|
||||
if (dartValue == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case ManagedPropertyType.bigInteger:
|
||||
return dartValue is int;
|
||||
case ManagedPropertyType.integer:
|
||||
return dartValue is int;
|
||||
case ManagedPropertyType.boolean:
|
||||
return dartValue is bool;
|
||||
case ManagedPropertyType.datetime:
|
||||
return dartValue is DateTime;
|
||||
case ManagedPropertyType.doublePrecision:
|
||||
return dartValue is double;
|
||||
case ManagedPropertyType.map:
|
||||
return dartValue is Map<String, dynamic>;
|
||||
case ManagedPropertyType.list:
|
||||
return dartValue is List<dynamic>;
|
||||
case ManagedPropertyType.document:
|
||||
return dartValue is Document;
|
||||
case ManagedPropertyType.string:
|
||||
{
|
||||
if (enumerationMap.isNotEmpty) {
|
||||
return enumerationMap.values.contains(dartValue);
|
||||
}
|
||||
return dartValue is String;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$kind";
|
||||
}
|
||||
|
||||
static List<Type> get supportedDartTypes {
|
||||
return [String, DateTime, bool, int, double, Document];
|
||||
}
|
||||
|
||||
static ManagedPropertyType get integer => ManagedPropertyType.integer;
|
||||
|
||||
static ManagedPropertyType get bigInteger => ManagedPropertyType.bigInteger;
|
||||
|
||||
static ManagedPropertyType get string => ManagedPropertyType.string;
|
||||
|
||||
static ManagedPropertyType get datetime => ManagedPropertyType.datetime;
|
||||
|
||||
static ManagedPropertyType get boolean => ManagedPropertyType.boolean;
|
||||
|
||||
static ManagedPropertyType get doublePrecision =>
|
||||
ManagedPropertyType.doublePrecision;
|
||||
|
||||
static ManagedPropertyType get map => ManagedPropertyType.map;
|
||||
|
||||
static ManagedPropertyType get list => ManagedPropertyType.list;
|
||||
|
||||
static ManagedPropertyType get document => ManagedPropertyType.document;
|
||||
}
|
65
packages/database/lib/src/managed/validation/impl.dart
Normal file
65
packages/database/lib/src/managed/validation/impl.dart
Normal file
|
@ -0,0 +1,65 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
|
||||
enum ValidateType { regex, comparison, length, present, absent, oneOf }
|
||||
|
||||
enum ValidationOperator {
|
||||
equalTo,
|
||||
lessThan,
|
||||
lessThanEqualTo,
|
||||
greaterThan,
|
||||
greaterThanEqualTo
|
||||
}
|
||||
|
||||
class ValidationExpression {
|
||||
ValidationExpression(this.operator, this.value);
|
||||
|
||||
final ValidationOperator operator;
|
||||
dynamic value;
|
||||
|
||||
void compare(ValidationContext context, dynamic input) {
|
||||
final comparisonValue = value as Comparable?;
|
||||
|
||||
switch (operator) {
|
||||
case ValidationOperator.equalTo:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) != 0) {
|
||||
context.addError("must be equal to '$comparisonValue'.");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidationOperator.greaterThan:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) >= 0) {
|
||||
context.addError("must be greater than '$comparisonValue'.");
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ValidationOperator.greaterThanEqualTo:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) > 0) {
|
||||
context.addError(
|
||||
"must be greater than or equal to '$comparisonValue'.",
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ValidationOperator.lessThan:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) <= 0) {
|
||||
context.addError("must be less than to '$comparisonValue'.");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidationOperator.lessThanEqualTo:
|
||||
{
|
||||
if (comparisonValue!.compareTo(input) < 0) {
|
||||
context
|
||||
.addError("must be less than or equal to '$comparisonValue'.");
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
105
packages/database/lib/src/managed/validation/managed.dart
Normal file
105
packages/database/lib/src/managed/validation/managed.dart
Normal file
|
@ -0,0 +1,105 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/validation/impl.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// Validates properties of [ManagedObject] before an insert or update [Query].
|
||||
///
|
||||
/// Instances of this type are created during [ManagedDataModel] compilation.
|
||||
class ManagedValidator {
|
||||
ManagedValidator(this.definition, this.state);
|
||||
|
||||
/// Executes all [Validate]s for [object].
|
||||
///
|
||||
/// Validates the properties of [object] according to its validator annotations. Validators
|
||||
/// are added to properties using [Validate] metadata.
|
||||
///
|
||||
/// This method does not invoke [ManagedObject.validate] - any customization provided
|
||||
/// by a [ManagedObject] subclass that overrides this method will not be invoked.
|
||||
static ValidationContext run(
|
||||
ManagedObject object, {
|
||||
Validating event = Validating.insert,
|
||||
}) {
|
||||
final context = ValidationContext();
|
||||
|
||||
for (final validator in object.entity.validators) {
|
||||
context.property = validator.property;
|
||||
context.event = event;
|
||||
context.state = validator.state;
|
||||
if (!validator.definition.runOnInsert && event == Validating.insert) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validator.definition.runOnUpdate && event == Validating.update) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var contents = object.backing.contents;
|
||||
String key = validator.property!.name;
|
||||
|
||||
if (validator.definition.type == ValidateType.present) {
|
||||
if (validator.property is ManagedRelationshipDescription) {
|
||||
final inner = object[validator.property!.name] as ManagedObject?;
|
||||
if (inner == null ||
|
||||
!inner.backing.contents.containsKey(inner.entity.primaryKey)) {
|
||||
context.addError("key '${validator.property!.name}' is required "
|
||||
"for ${_getEventName(event)}s.");
|
||||
}
|
||||
} else if (!contents.containsKey(key)) {
|
||||
context.addError("key '${validator.property!.name}' is required "
|
||||
"for ${_getEventName(event)}s.");
|
||||
}
|
||||
} else if (validator.definition.type == ValidateType.absent) {
|
||||
if (validator.property is ManagedRelationshipDescription) {
|
||||
final inner = object[validator.property!.name] as ManagedObject?;
|
||||
if (inner != null) {
|
||||
context.addError("key '${validator.property!.name}' is not allowed "
|
||||
"for ${_getEventName(event)}s.");
|
||||
}
|
||||
} else if (contents.containsKey(key)) {
|
||||
context.addError("key '${validator.property!.name}' is not allowed "
|
||||
"for ${_getEventName(event)}s.");
|
||||
}
|
||||
} else {
|
||||
if (validator.property is ManagedRelationshipDescription) {
|
||||
final inner = object[validator.property!.name] as ManagedObject?;
|
||||
if (inner == null ||
|
||||
inner.backing.contents[inner.entity.primaryKey] == null) {
|
||||
continue;
|
||||
}
|
||||
contents = inner.backing.contents;
|
||||
key = inner.entity.primaryKey;
|
||||
}
|
||||
|
||||
final value = contents[key];
|
||||
if (value != null) {
|
||||
validator.validate(context, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/// The property being validated.
|
||||
ManagedPropertyDescription? property;
|
||||
|
||||
/// The metadata associated with this instance.
|
||||
final Validate definition;
|
||||
|
||||
final dynamic state;
|
||||
|
||||
void validate(ValidationContext context, dynamic value) {
|
||||
definition.validate(context, value);
|
||||
}
|
||||
|
||||
static String _getEventName(Validating op) {
|
||||
switch (op) {
|
||||
case Validating.insert:
|
||||
return "insert";
|
||||
case Validating.update:
|
||||
return "update";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
650
packages/database/lib/src/managed/validation/metadata.dart
Normal file
650
packages/database/lib/src/managed/validation/metadata.dart
Normal file
|
@ -0,0 +1,650 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/db.dart';
|
||||
import 'package:protevus_database/src/managed/validation/impl.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// Types of operations [ManagedValidator]s will be triggered for.
|
||||
enum Validating { update, insert }
|
||||
|
||||
/// Information about a validation being performed.
|
||||
class ValidationContext {
|
||||
/// Whether this validation is occurring during update or insert.
|
||||
late Validating event;
|
||||
|
||||
/// The property being validated.
|
||||
ManagedPropertyDescription? property;
|
||||
|
||||
/// State associated with the validator being run.
|
||||
///
|
||||
/// Use this property in a custom validator to access compiled state. Compiled state
|
||||
/// is a value that has been computed from the arguments to the validator. For example,
|
||||
/// a 'greater than 1' validator, the state is an expression object that evaluates
|
||||
/// a value is greater than 1.
|
||||
///
|
||||
/// Set this property by returning the desired value from [Validate.compare].
|
||||
dynamic state;
|
||||
|
||||
/// Errors that have occurred in this context.
|
||||
List<String> errors = [];
|
||||
|
||||
/// Adds a validation error to the context.
|
||||
///
|
||||
/// A validation will fail if this method is invoked.
|
||||
void addError(String reason) {
|
||||
final p = property;
|
||||
if (p is ManagedRelationshipDescription) {
|
||||
errors.add(
|
||||
"${p.entity.name}.${p.name}.${p.destinationEntity.primaryKey}: $reason",
|
||||
);
|
||||
} else {
|
||||
errors.add("${p!.entity.name}.${p.name}: $reason");
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this validation context passed all validations.
|
||||
bool get isValid => errors.isEmpty;
|
||||
}
|
||||
|
||||
/// An error thrown during validator compilation.
|
||||
///
|
||||
/// If you override [Validate.compile], throw errors of this type if a validator
|
||||
/// is applied to an invalid property.
|
||||
class ValidateCompilationError extends Error {
|
||||
ValidateCompilationError(this.reason);
|
||||
|
||||
final String reason;
|
||||
}
|
||||
|
||||
/// Add as metadata to persistent properties to validate their values before insertion or updating.
|
||||
///
|
||||
/// When executing update or insert queries, any properties with this metadata will be validated
|
||||
/// against the condition declared by this instance. Example:
|
||||
///
|
||||
/// class Person extends ManagedObject<_Person> implements _Person {}
|
||||
/// class _Person {
|
||||
/// @primaryKey
|
||||
/// int id;
|
||||
///
|
||||
/// @Validate.length(greaterThan: 10)
|
||||
/// String name;
|
||||
/// }
|
||||
///
|
||||
/// Properties may have more than one metadata of this type. All validations must pass
|
||||
/// for an insert or update to be valid.
|
||||
///
|
||||
/// By default, validations occur on update and insert queries. Constructors have arguments
|
||||
/// for only running a validation on insert or update. See [runOnUpdate] and [runOnInsert].
|
||||
///
|
||||
/// This class may be subclassed to create custom validations. Subclasses must override [validate].
|
||||
class Validate {
|
||||
/// Invoke this constructor when creating custom subclasses.
|
||||
///
|
||||
/// This constructor is used so that subclasses can pass [onUpdate] and [onInsert].
|
||||
/// Example:
|
||||
/// class CustomValidate extends Validate<String> {
|
||||
/// CustomValidate({bool onUpdate: true, bool onInsert: true})
|
||||
/// : super(onUpdate: onUpdate, onInsert: onInsert);
|
||||
///
|
||||
/// bool validate(
|
||||
/// ValidateOperation operation,
|
||||
/// ManagedAttributeDescription property,
|
||||
/// String value,
|
||||
/// List<String> errors) {
|
||||
/// return someCondition;
|
||||
/// }
|
||||
/// }
|
||||
const Validate({bool onUpdate = true, bool onInsert = true})
|
||||
: runOnUpdate = onUpdate,
|
||||
runOnInsert = onInsert,
|
||||
_value = null,
|
||||
_lessThan = null,
|
||||
_lessThanEqualTo = null,
|
||||
_greaterThan = null,
|
||||
_greaterThanEqualTo = null,
|
||||
_equalTo = null,
|
||||
type = null;
|
||||
|
||||
const Validate._({
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
ValidateType? validator,
|
||||
dynamic value,
|
||||
Comparable? greaterThan,
|
||||
Comparable? greaterThanEqualTo,
|
||||
Comparable? equalTo,
|
||||
Comparable? lessThan,
|
||||
Comparable? lessThanEqualTo,
|
||||
}) : runOnUpdate = onUpdate,
|
||||
runOnInsert = onInsert,
|
||||
type = validator,
|
||||
_value = value,
|
||||
_greaterThan = greaterThan,
|
||||
_greaterThanEqualTo = greaterThanEqualTo,
|
||||
_equalTo = equalTo,
|
||||
_lessThan = lessThan,
|
||||
_lessThanEqualTo = lessThanEqualTo;
|
||||
|
||||
/// A validator for matching an input String against a regular expression.
|
||||
///
|
||||
/// Values passing through validators of this type must match a regular expression
|
||||
/// created by [pattern]. See [RegExp] in the Dart standard library for behavior.
|
||||
///
|
||||
/// This validator is only valid for [String] properties.
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation is run on update queries.
|
||||
/// If [onInsert] is true (the default), this validation is run on insert queries.
|
||||
const Validate.matches(
|
||||
String pattern, {
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
}) : this._(
|
||||
value: pattern,
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.regex,
|
||||
);
|
||||
|
||||
/// A validator for comparing a value.
|
||||
///
|
||||
/// Values passing through validators of this type must be [lessThan],
|
||||
/// [greaterThan], [lessThanEqualTo], [equalTo], or [greaterThanEqualTo
|
||||
/// to the value provided for each argument.
|
||||
///
|
||||
/// Any argument not specified is not evaluated. A typical validator
|
||||
/// only uses one argument:
|
||||
///
|
||||
/// @Validate.compare(lessThan: 10.0)
|
||||
/// double value;
|
||||
///
|
||||
/// All provided arguments are evaluated. Therefore, the following
|
||||
/// requires an input value to be between 6 and 10:
|
||||
///
|
||||
/// @Validate.compare(greaterThanEqualTo: 6, lessThanEqualTo: 10)
|
||||
/// int value;
|
||||
///
|
||||
/// This validator can be used for [String], [double], [int] and [DateTime] properties.
|
||||
///
|
||||
/// When creating a validator for [DateTime] properties, the value for an argument
|
||||
/// is a [String] that will be parsed by [DateTime.parse].
|
||||
///
|
||||
/// @Validate.compare(greaterThan: "2017-02-11T00:30:00Z")
|
||||
/// DateTime date;
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation is run on update queries.
|
||||
/// If [onInsert] is true (the default), this validation is run on insert queries.
|
||||
const Validate.compare({
|
||||
Comparable? lessThan,
|
||||
Comparable? greaterThan,
|
||||
Comparable? equalTo,
|
||||
Comparable? greaterThanEqualTo,
|
||||
Comparable? lessThanEqualTo,
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
}) : this._(
|
||||
lessThan: lessThan,
|
||||
lessThanEqualTo: lessThanEqualTo,
|
||||
greaterThan: greaterThan,
|
||||
greaterThanEqualTo: greaterThanEqualTo,
|
||||
equalTo: equalTo,
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.comparison,
|
||||
);
|
||||
|
||||
/// A validator for validating the length of a [String].
|
||||
///
|
||||
/// Values passing through validators of this type must a [String] with a length that is[lessThan],
|
||||
/// [greaterThan], [lessThanEqualTo], [equalTo], or [greaterThanEqualTo
|
||||
/// to the value provided for each argument.
|
||||
///
|
||||
/// Any argument not specified is not evaluated. A typical validator
|
||||
/// only uses one argument:
|
||||
///
|
||||
/// @Validate.length(lessThan: 10)
|
||||
/// String foo;
|
||||
///
|
||||
/// All provided arguments are evaluated. Therefore, the following
|
||||
/// requires an input string to have a length to be between 6 and 10:
|
||||
///
|
||||
/// @Validate.length(greaterThanEqualTo: 6, lessThanEqualTo: 10)
|
||||
/// String foo;
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation is run on update queries.
|
||||
/// If [onInsert] is true (the default), this validation is run on insert queries.
|
||||
const Validate.length({
|
||||
int? lessThan,
|
||||
int? greaterThan,
|
||||
int? equalTo,
|
||||
int? greaterThanEqualTo,
|
||||
int? lessThanEqualTo,
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
}) : this._(
|
||||
lessThan: lessThan,
|
||||
lessThanEqualTo: lessThanEqualTo,
|
||||
greaterThan: greaterThan,
|
||||
greaterThanEqualTo: greaterThanEqualTo,
|
||||
equalTo: equalTo,
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.length,
|
||||
);
|
||||
|
||||
/// A validator for ensuring a property always has a value when being inserted or updated.
|
||||
///
|
||||
/// This metadata requires that a property must be set in [Query.values] before an update
|
||||
/// or insert. The value may be null, if the property's [Column.isNullable] allow it.
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation requires a property to be present for update queries.
|
||||
/// If [onInsert] is true (the default), this validation requires a property to be present for insert queries.
|
||||
const Validate.present({bool onUpdate = true, bool onInsert = true})
|
||||
: this._(
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.present,
|
||||
);
|
||||
|
||||
/// A validator for ensuring a property does not have a value when being inserted or updated.
|
||||
///
|
||||
/// This metadata requires that a property must NOT be set in [Query.values] before an update
|
||||
/// or insert.
|
||||
///
|
||||
/// This validation is used to restrict input during either an insert or update query. For example,
|
||||
/// a 'dateCreated' property would use this validator to ensure that property isn't set during an update.
|
||||
///
|
||||
/// @Validate.absent(onUpdate: true, onInsert: false)
|
||||
/// DateTime dateCreated;
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation requires a property to be absent for update queries.
|
||||
/// If [onInsert] is true (the default), this validation requires a property to be absent for insert queries.
|
||||
const Validate.absent({bool onUpdate = true, bool onInsert = true})
|
||||
: this._(
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.absent,
|
||||
);
|
||||
|
||||
/// A validator for ensuring a value is one of a set of values.
|
||||
///
|
||||
/// An input value must be one of [values].
|
||||
///
|
||||
/// [values] must be homogenous - every value must be the same type -
|
||||
/// and the property with this metadata must also match the type
|
||||
/// of the objects in [values].
|
||||
///
|
||||
/// This validator can be used for [String] and [int] properties.
|
||||
///
|
||||
/// @Validate.oneOf(const ["A", "B", "C")
|
||||
/// String foo;
|
||||
///
|
||||
/// If [onUpdate] is true (the default), this validation is run on update queries.
|
||||
/// If [onInsert] is true (the default), this validation is run on insert queries.
|
||||
const Validate.oneOf(
|
||||
List<dynamic> values, {
|
||||
bool onUpdate = true,
|
||||
bool onInsert = true,
|
||||
}) : this._(
|
||||
value: values,
|
||||
onUpdate: onUpdate,
|
||||
onInsert: onInsert,
|
||||
validator: ValidateType.oneOf,
|
||||
);
|
||||
|
||||
/// A validator that ensures a value cannot be modified after insertion.
|
||||
///
|
||||
/// This is equivalent to `Validate.absent(onUpdate: true, onInsert: false).
|
||||
const Validate.constant() : this.absent(onUpdate: true, onInsert: false);
|
||||
|
||||
/// Whether or not this validation is checked on update queries.
|
||||
final bool runOnUpdate;
|
||||
|
||||
/// Whether or not this validation is checked on insert queries.
|
||||
final bool runOnInsert;
|
||||
|
||||
final dynamic _value;
|
||||
final Comparable? _greaterThan;
|
||||
final Comparable? _greaterThanEqualTo;
|
||||
final Comparable? _equalTo;
|
||||
final Comparable? _lessThan;
|
||||
final Comparable? _lessThanEqualTo;
|
||||
final ValidateType? type;
|
||||
|
||||
/// Subclasses override this method to perform any one-time initialization tasks and check for correctness.
|
||||
///
|
||||
/// Use this method to ensure a validator is being applied to a property correctly. For example, a
|
||||
/// [Validate.compare] builds a list of expressions and ensures each expression's values are the
|
||||
/// same type as the property being validated.
|
||||
///
|
||||
/// The value returned from this method is available in [ValidationContext.state] when this
|
||||
/// instance's [validate] method is called.
|
||||
///
|
||||
/// [typeBeingValidated] is the type of the property being validated. If [relationshipInverseType] is not-null,
|
||||
/// it is a [ManagedObject] subclass and [typeBeingValidated] is the type of its primary key.
|
||||
///
|
||||
/// If compilation fails, throw a [ValidateCompilationError] with a message describing the issue. The entity
|
||||
/// and property will automatically be added to the error.
|
||||
dynamic compile(
|
||||
ManagedType typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
switch (type) {
|
||||
case ValidateType.absent:
|
||||
return null;
|
||||
case ValidateType.present:
|
||||
return null;
|
||||
case ValidateType.oneOf:
|
||||
{
|
||||
return _oneOfCompiler(
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
}
|
||||
case ValidateType.comparison:
|
||||
return _comparisonCompiler(
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
case ValidateType.regex:
|
||||
return _regexCompiler(
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
case ValidateType.length:
|
||||
return _lengthCompiler(
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the [input] value.
|
||||
///
|
||||
/// Subclasses override this method to provide validation behavior.
|
||||
///
|
||||
/// [input] is the value being validated. If the value is invalid, the reason
|
||||
/// is added to [context] via [ValidationContext.addError].
|
||||
///
|
||||
/// Additional information about the validation event and the attribute being evaluated
|
||||
/// is available in [context].
|
||||
/// in [context].
|
||||
///
|
||||
/// This method is not run when [input] is null.
|
||||
///
|
||||
/// The type of [input] will have already been type-checked prior to executing this method.
|
||||
void validate(ValidationContext context, dynamic input) {
|
||||
switch (type!) {
|
||||
case ValidateType.absent:
|
||||
{}
|
||||
break;
|
||||
case ValidateType.present:
|
||||
{}
|
||||
break;
|
||||
case ValidateType.comparison:
|
||||
{
|
||||
final expressions = context.state as List<ValidationExpression>;
|
||||
for (final expr in expressions) {
|
||||
expr.compare(context, input);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.regex:
|
||||
{
|
||||
final regex = context.state as RegExp;
|
||||
if (!regex.hasMatch(input as String)) {
|
||||
context.addError("does not match pattern ${regex.pattern}");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.oneOf:
|
||||
{
|
||||
final options = context.state as List<dynamic>;
|
||||
if (options.every((v) => input != v)) {
|
||||
context.addError(
|
||||
"must be one of: ${options.map((v) => "'$v'").join(",")}.",
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.length:
|
||||
{
|
||||
final expressions = context.state as List<ValidationExpression>;
|
||||
for (final expr in expressions) {
|
||||
expr.compare(context, (input as String).length);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds constraints to an [APISchemaObject] imposed by this validator.
|
||||
///
|
||||
/// Used during documentation process. When creating custom validator subclasses, override this method
|
||||
/// to modify [object] for any constraints the validator imposes.
|
||||
void constrainSchemaObject(
|
||||
APIDocumentContext context,
|
||||
APISchemaObject object,
|
||||
) {
|
||||
switch (type!) {
|
||||
case ValidateType.regex:
|
||||
{
|
||||
object.pattern = _value as String?;
|
||||
}
|
||||
break;
|
||||
case ValidateType.comparison:
|
||||
{
|
||||
if (_greaterThan is num) {
|
||||
object.exclusiveMinimum = true;
|
||||
object.minimum = _greaterThan as num?;
|
||||
} else if (_greaterThanEqualTo is num) {
|
||||
object.exclusiveMinimum = false;
|
||||
object.minimum = _greaterThanEqualTo as num?;
|
||||
}
|
||||
|
||||
if (_lessThan is num) {
|
||||
object.exclusiveMaximum = true;
|
||||
object.maximum = _lessThan as num?;
|
||||
} else if (_lessThanEqualTo is num) {
|
||||
object.exclusiveMaximum = false;
|
||||
object.maximum = _lessThanEqualTo as num?;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.length:
|
||||
{
|
||||
if (_equalTo != null) {
|
||||
object.maxLength = _equalTo as int;
|
||||
object.minLength = _equalTo;
|
||||
} else {
|
||||
if (_greaterThan is int) {
|
||||
object.minLength = 1 + (_greaterThan);
|
||||
} else if (_greaterThanEqualTo is int) {
|
||||
object.minLength = _greaterThanEqualTo as int?;
|
||||
}
|
||||
|
||||
if (_lessThan is int) {
|
||||
object.maxLength = (-1) + (_lessThan);
|
||||
} else if (_lessThanEqualTo != null) {
|
||||
object.maximum = _lessThanEqualTo as int?;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ValidateType.present:
|
||||
{}
|
||||
break;
|
||||
case ValidateType.absent:
|
||||
{}
|
||||
break;
|
||||
case ValidateType.oneOf:
|
||||
{
|
||||
object.enumerated = _value as List<dynamic>?;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _oneOfCompiler(
|
||||
ManagedType typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
if (_value is! List) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.oneOf value must be a List<T>, where T is the type of the property being validated.",
|
||||
);
|
||||
}
|
||||
|
||||
final options = _value;
|
||||
final supportedOneOfTypes = [
|
||||
ManagedPropertyType.string,
|
||||
ManagedPropertyType.integer,
|
||||
ManagedPropertyType.bigInteger
|
||||
];
|
||||
if (!supportedOneOfTypes.contains(typeBeingValidated.kind) ||
|
||||
relationshipInverseType != null) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.oneOf is only valid for String or int types.",
|
||||
);
|
||||
}
|
||||
|
||||
if (options.any((v) => !typeBeingValidated.isAssignableWith(v))) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.oneOf value must be a List<T>, where T is the type of the property being validated.",
|
||||
);
|
||||
}
|
||||
|
||||
if (options.isEmpty) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.oneOf must have at least one element.",
|
||||
);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
List<ValidationExpression> get _expressions {
|
||||
final comparisons = <ValidationExpression>[];
|
||||
if (_equalTo != null) {
|
||||
comparisons
|
||||
.add(ValidationExpression(ValidationOperator.equalTo, _equalTo));
|
||||
}
|
||||
if (_lessThan != null) {
|
||||
comparisons
|
||||
.add(ValidationExpression(ValidationOperator.lessThan, _lessThan));
|
||||
}
|
||||
if (_lessThanEqualTo != null) {
|
||||
comparisons.add(
|
||||
ValidationExpression(
|
||||
ValidationOperator.lessThanEqualTo,
|
||||
_lessThanEqualTo,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_greaterThan != null) {
|
||||
comparisons.add(
|
||||
ValidationExpression(ValidationOperator.greaterThan, _greaterThan),
|
||||
);
|
||||
}
|
||||
if (_greaterThanEqualTo != null) {
|
||||
comparisons.add(
|
||||
ValidationExpression(
|
||||
ValidationOperator.greaterThanEqualTo,
|
||||
_greaterThanEqualTo,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return comparisons;
|
||||
}
|
||||
|
||||
dynamic _comparisonCompiler(
|
||||
ManagedType? typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
final exprs = _expressions;
|
||||
for (final expr in exprs) {
|
||||
expr.value = _parseComparisonValue(
|
||||
expr.value,
|
||||
typeBeingValidated,
|
||||
relationshipInverseType: relationshipInverseType,
|
||||
);
|
||||
}
|
||||
return exprs;
|
||||
}
|
||||
|
||||
Comparable? _parseComparisonValue(
|
||||
dynamic referenceValue,
|
||||
ManagedType? typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
if (typeBeingValidated?.kind == ManagedPropertyType.datetime) {
|
||||
if (referenceValue is String) {
|
||||
try {
|
||||
return DateTime.parse(referenceValue);
|
||||
} on FormatException {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.compare value '$referenceValue' cannot be parsed as expected DateTime type.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw ValidateCompilationError(
|
||||
"Validate.compare value '$referenceValue' is not expected DateTime type.",
|
||||
);
|
||||
}
|
||||
|
||||
if (relationshipInverseType == null) {
|
||||
if (!typeBeingValidated!.isAssignableWith(referenceValue)) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.compare value '$referenceValue' is not assignable to type of attribute being validated.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (!typeBeingValidated!.isAssignableWith(referenceValue)) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.compare value '$referenceValue' is not assignable to primary key type of relationship being validated.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return referenceValue as Comparable?;
|
||||
}
|
||||
|
||||
dynamic _regexCompiler(
|
||||
ManagedType? typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
if (typeBeingValidated?.kind != ManagedPropertyType.string) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.matches is only valid for 'String' properties.",
|
||||
);
|
||||
}
|
||||
|
||||
if (_value is! String) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.matches argument must be 'String'.",
|
||||
);
|
||||
}
|
||||
|
||||
return RegExp(_value);
|
||||
}
|
||||
|
||||
dynamic _lengthCompiler(
|
||||
ManagedType typeBeingValidated, {
|
||||
Type? relationshipInverseType,
|
||||
}) {
|
||||
if (typeBeingValidated.kind != ManagedPropertyType.string) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.length is only valid for 'String' properties.",
|
||||
);
|
||||
}
|
||||
final expressions = _expressions;
|
||||
if (expressions.any((v) => v.value is! int)) {
|
||||
throw ValidateCompilationError(
|
||||
"Validate.length arguments must be 'int's.",
|
||||
);
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
}
|
100
packages/database/lib/src/persistent_store/persistent_store.dart
Normal file
100
packages/database/lib/src/persistent_store/persistent_store.dart
Normal file
|
@ -0,0 +1,100 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_database/src/managed/context.dart';
|
||||
import 'package:protevus_database/src/managed/entity.dart';
|
||||
import 'package:protevus_database/src/managed/object.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
enum PersistentStoreQueryReturnType { rowCount, rows }
|
||||
|
||||
/// An interface for implementing persistent storage.
|
||||
///
|
||||
/// You rarely need to use this class directly. See [Query] for how to interact with instances of this class.
|
||||
/// Implementors of this class serve as the bridge between [Query]s and a specific database.
|
||||
abstract class PersistentStore {
|
||||
/// Creates a new database-specific [Query].
|
||||
///
|
||||
/// Subclasses override this method to provide a concrete implementation of [Query]
|
||||
/// specific to this type. Objects returned from this method must implement [Query]. They
|
||||
/// should mixin [QueryMixin] to most of the behavior provided by a query.
|
||||
Query<T> newQuery<T extends ManagedObject>(
|
||||
ManagedContext context,
|
||||
ManagedEntity entity, {
|
||||
T? values,
|
||||
});
|
||||
|
||||
/// Executes an arbitrary command.
|
||||
Future execute(String sql, {Map<String, dynamic>? substitutionValues});
|
||||
|
||||
Future<dynamic> executeQuery(
|
||||
String formatString,
|
||||
Map<String, dynamic> values,
|
||||
int timeoutInSeconds, {
|
||||
PersistentStoreQueryReturnType? returnType,
|
||||
});
|
||||
|
||||
Future<T> transaction<T>(
|
||||
ManagedContext transactionContext,
|
||||
Future<T> Function(ManagedContext transaction) transactionBlock,
|
||||
);
|
||||
|
||||
/// Closes the underlying database connection.
|
||||
Future close();
|
||||
|
||||
// -- Schema Ops --
|
||||
|
||||
List<String> createTable(SchemaTable table, {bool isTemporary = false});
|
||||
|
||||
List<String> renameTable(SchemaTable table, String name);
|
||||
|
||||
List<String> deleteTable(SchemaTable table);
|
||||
|
||||
List<String> addTableUniqueColumnSet(SchemaTable table);
|
||||
|
||||
List<String> deleteTableUniqueColumnSet(SchemaTable table);
|
||||
|
||||
List<String> addColumn(
|
||||
SchemaTable table,
|
||||
SchemaColumn column, {
|
||||
String? unencodedInitialValue,
|
||||
});
|
||||
|
||||
List<String> deleteColumn(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> renameColumn(
|
||||
SchemaTable table,
|
||||
SchemaColumn column,
|
||||
String name,
|
||||
);
|
||||
|
||||
List<String> alterColumnNullability(
|
||||
SchemaTable table,
|
||||
SchemaColumn column,
|
||||
String? unencodedInitialValue,
|
||||
);
|
||||
|
||||
List<String> alterColumnUniqueness(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> alterColumnDefaultValue(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> alterColumnDeleteRule(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> addIndexToColumn(SchemaTable table, SchemaColumn column);
|
||||
|
||||
List<String> renameIndex(
|
||||
SchemaTable table,
|
||||
SchemaColumn column,
|
||||
String newIndexName,
|
||||
);
|
||||
|
||||
List<String> deleteIndexFromColumn(SchemaTable table, SchemaColumn column);
|
||||
|
||||
Future<int> get schemaVersion;
|
||||
|
||||
Future<Schema> upgrade(
|
||||
Schema fromSchema,
|
||||
List<Migration> withMigrations, {
|
||||
bool temporary = false,
|
||||
});
|
||||
}
|
91
packages/database/lib/src/query/error.dart
Normal file
91
packages/database/lib/src/query/error.dart
Normal file
|
@ -0,0 +1,91 @@
|
|||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// An exception describing an issue with a query.
|
||||
///
|
||||
/// A suggested HTTP status code based on the type of exception will always be available.
|
||||
class QueryException<T> implements HandlerException {
|
||||
QueryException(
|
||||
this.event, {
|
||||
this.message,
|
||||
this.underlyingException,
|
||||
this.offendingItems,
|
||||
});
|
||||
|
||||
QueryException.input(
|
||||
this.message,
|
||||
this.offendingItems, {
|
||||
this.underlyingException,
|
||||
}) : event = QueryExceptionEvent.input;
|
||||
QueryException.transport(this.message, {this.underlyingException})
|
||||
: event = QueryExceptionEvent.transport,
|
||||
offendingItems = null;
|
||||
QueryException.conflict(
|
||||
this.message,
|
||||
this.offendingItems, {
|
||||
this.underlyingException,
|
||||
}) : event = QueryExceptionEvent.conflict;
|
||||
|
||||
final String? message;
|
||||
|
||||
/// The exception generated by the [PersistentStore] or other mechanism that caused [Query] to fail.
|
||||
final T? underlyingException;
|
||||
|
||||
/// The type of event that caused this exception.
|
||||
final QueryExceptionEvent event;
|
||||
|
||||
final List<String>? offendingItems;
|
||||
|
||||
@override
|
||||
Response get response {
|
||||
return Response(_getStatus(event), null, _getBody(message, offendingItems));
|
||||
}
|
||||
|
||||
static Map<String, String> _getBody(
|
||||
String? message,
|
||||
List<String>? offendingItems,
|
||||
) {
|
||||
final body = {
|
||||
"error": message ?? "query failed",
|
||||
};
|
||||
|
||||
if (offendingItems != null && offendingItems.isNotEmpty) {
|
||||
body["detail"] = "Offending Items: ${offendingItems.join(", ")}";
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
static int _getStatus(QueryExceptionEvent event) {
|
||||
switch (event) {
|
||||
case QueryExceptionEvent.input:
|
||||
return 400;
|
||||
case QueryExceptionEvent.transport:
|
||||
return 503;
|
||||
case QueryExceptionEvent.conflict:
|
||||
return 409;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => "Query failed: $message. Reason: $underlyingException";
|
||||
}
|
||||
|
||||
/// Categorizations of query failures for [QueryException].
|
||||
enum QueryExceptionEvent {
|
||||
/// This event is used when the underlying [PersistentStore] reports that a unique constraint was violated.
|
||||
///
|
||||
/// [Controller]s interpret this exception to return a status code 409 by default.
|
||||
conflict,
|
||||
|
||||
/// This event is used when the underlying [PersistentStore] cannot reach its database.
|
||||
///
|
||||
/// [Controller]s interpret this exception to return a status code 503 by default.
|
||||
transport,
|
||||
|
||||
/// This event is used when the underlying [PersistentStore] reports an issue with the data used in a [Query].
|
||||
///
|
||||
/// [Controller]s interpret this exception to return a status code 400 by default.
|
||||
input,
|
||||
}
|
431
packages/database/lib/src/query/matcher_expression.dart
Normal file
431
packages/database/lib/src/query/matcher_expression.dart
Normal file
|
@ -0,0 +1,431 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// Contains binary logic operations to be applied to a [QueryExpression].
|
||||
class QueryExpressionJunction<T, InstanceType> {
|
||||
QueryExpressionJunction._(this.lhs);
|
||||
|
||||
final QueryExpression<T, InstanceType> lhs;
|
||||
}
|
||||
|
||||
/// Contains methods for adding logical expressions to properties when building a [Query].
|
||||
///
|
||||
/// You do not create instances of this type directly, but instead are returned an instance when selecting a property
|
||||
/// of an object in [Query.where]. You invoke methods from this type to add an expression to the query for the selected property.
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<Employee>()
|
||||
/// ..where((e) => e.name).equalTo("Bob");
|
||||
///
|
||||
class QueryExpression<T, InstanceType> {
|
||||
QueryExpression(this.keyPath);
|
||||
|
||||
QueryExpression.byAddingKey(
|
||||
QueryExpression<T, InstanceType> original,
|
||||
ManagedPropertyDescription byAdding,
|
||||
) : keyPath = KeyPath.byAddingKey(original.keyPath, byAdding),
|
||||
_expression = original.expression;
|
||||
|
||||
final KeyPath keyPath;
|
||||
|
||||
// todo: This needs to be extended to an expr tree
|
||||
PredicateExpression? get expression => _expression;
|
||||
|
||||
set expression(PredicateExpression? expr) {
|
||||
if (_invertNext) {
|
||||
_expression = expr!.inverse;
|
||||
_invertNext = false;
|
||||
} else {
|
||||
_expression = expr;
|
||||
}
|
||||
}
|
||||
|
||||
bool _invertNext = false;
|
||||
PredicateExpression? _expression;
|
||||
|
||||
QueryExpressionJunction<T, InstanceType> _createJunction() =>
|
||||
QueryExpressionJunction<T, InstanceType>._(this);
|
||||
|
||||
/// Inverts the next expression.
|
||||
///
|
||||
/// You use this method to apply an inversion to the expression that follows. For example,
|
||||
/// the following example would only return objects where the 'id' is *not* equal to '5'.
|
||||
///
|
||||
/// final query = new Query<Employee>()
|
||||
/// ..where((e) => e.name).not.equalTo("Bob");
|
||||
QueryExpression<T, InstanceType> get not {
|
||||
_invertNext = !_invertNext;
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Adds an equality expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is equal to [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [bool], [double] and [DateTime] types.
|
||||
///
|
||||
/// If [value] is [String], the flag [caseSensitive] controls whether or not equality is case-sensitively compared.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<User>()
|
||||
/// ..where((u) => u.id ).equalTo(1);
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> equalTo(
|
||||
T value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
if (value is String) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.equals,
|
||||
caseSensitive: caseSensitive,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
} else {
|
||||
expression = ComparisonExpression(value, PredicateOperator.equalTo);
|
||||
}
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'not equal' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is *not* equal to [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [bool], [double] and [DateTime] types.
|
||||
///
|
||||
/// If [value] is [String], the flag [caseSensitive] controls whether or not equality is case-sensitively compared.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<Employee>()
|
||||
/// ..where((e) => e.id).notEqualTo(60000);
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> notEqualTo(
|
||||
T value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
if (value is String) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.equals,
|
||||
caseSensitive: caseSensitive,
|
||||
invertOperator: true,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
} else {
|
||||
expression = ComparisonExpression(value, PredicateOperator.notEqual);
|
||||
}
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a like expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is like [value].
|
||||
///
|
||||
/// For more documentation on postgres pattern matching, see
|
||||
/// https://www.postgresql.org/docs/10/functions-matching.html.
|
||||
///
|
||||
/// This method can be used on [String] types.
|
||||
///
|
||||
/// The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<User>()
|
||||
/// ..where((u) => u.name ).like("bob");
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> like(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.equals,
|
||||
caseSensitive: caseSensitive,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'not like' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is *not* like [value].
|
||||
///
|
||||
/// For more documentation on postgres pattern matching, see
|
||||
/// https://www.postgresql.org/docs/10/functions-matching.html.
|
||||
///
|
||||
/// This method can be used on [String] types.
|
||||
///
|
||||
/// The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// final query = new Query<Employee>()
|
||||
/// ..where((e) => e.id).notEqualTo(60000);
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> notLike(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.equals,
|
||||
caseSensitive: caseSensitive,
|
||||
invertOperator: true,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'greater than' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is greater than [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'later than' [value]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'after' [value].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).greaterThan(60000);
|
||||
QueryExpressionJunction<T, InstanceType> greaterThan(T value) {
|
||||
expression = ComparisonExpression(value, PredicateOperator.greaterThan);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'greater than or equal to' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is greater than [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'later than or the same time as' [value]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'after or the same as' [value].
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).greaterThanEqualTo(60000);
|
||||
QueryExpressionJunction<T, InstanceType> greaterThanEqualTo(T value) {
|
||||
expression =
|
||||
ComparisonExpression(value, PredicateOperator.greaterThanEqualTo);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'less than' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is less than [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'earlier than' [value]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'before' [value].
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).lessThan(60000);
|
||||
QueryExpressionJunction<T, InstanceType> lessThan(T value) {
|
||||
expression = ComparisonExpression(value, PredicateOperator.lessThan);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'less than or equal to' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is less than or equal to [value].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'earlier than or the same time as' [value]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'before or the same as' [value].
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).lessThanEqualTo(60000);
|
||||
QueryExpressionJunction<T, InstanceType> lessThanEqualTo(T value) {
|
||||
expression = ComparisonExpression(value, PredicateOperator.lessThanEqualTo);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'contains string' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property contains the string [value].
|
||||
///
|
||||
/// This method can be used on [String] types. The substring [value] must be found in the stored string.
|
||||
/// The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((s) => s.title).contains("Director");
|
||||
///
|
||||
QueryExpressionJunction<T, InstanceType> contains(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.contains,
|
||||
caseSensitive: caseSensitive,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'begins with string' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is begins with the string [value].
|
||||
///
|
||||
/// This method can be used on [String] types. The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((s) => s.name).beginsWith("B");
|
||||
QueryExpressionJunction<T, InstanceType> beginsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.beginsWith,
|
||||
caseSensitive: caseSensitive,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'ends with string' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is ends with the string [value].
|
||||
///
|
||||
/// This method can be used on [String] types. The flag [caseSensitive] controls whether strings are compared case-sensitively.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.name).endsWith("son");
|
||||
QueryExpressionJunction<T, InstanceType> endsWith(
|
||||
String value, {
|
||||
bool caseSensitive = true,
|
||||
}) {
|
||||
expression = StringExpression(
|
||||
value,
|
||||
PredicateStringOperator.endsWith,
|
||||
caseSensitive: caseSensitive,
|
||||
allowSpecialCharacters: false,
|
||||
);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'equal to one of' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is equal to one of the [values].
|
||||
///
|
||||
/// This method can be used on [String], [int], [double], [bool] and [DateTime] types.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.department).oneOf(["Engineering", "HR"]);
|
||||
QueryExpressionJunction<T, InstanceType> oneOf(Iterable<T> values) {
|
||||
if (values.isEmpty) {
|
||||
throw ArgumentError(
|
||||
"'Query.where.oneOf' cannot be the empty set or null.",
|
||||
);
|
||||
}
|
||||
expression = SetMembershipExpression(values.toList());
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'between two values' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is between than [lhs] and [rhs].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'later than' [lhs] and 'earlier than' [rhs]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'after' [lhs] and 'before' [rhs].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).between(80000, 100000);
|
||||
QueryExpressionJunction<T, InstanceType> between(T lhs, T rhs) {
|
||||
expression = RangeExpression(lhs, rhs);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'outside of the range crated by two values' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is not within the range established by [lhs] to [rhs].
|
||||
///
|
||||
/// This method can be used on [int], [String], [double] and [DateTime] types. For [DateTime] properties,
|
||||
/// this method selects rows where the assigned property is 'later than' [rhs] and 'earlier than' [lhs]. For [String] properties,
|
||||
/// rows are selected if the value is alphabetically 'before' [lhs] and 'after' [rhs].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = new Query<Employee>()
|
||||
/// ..where((e) => e.salary).outsideOf(80000, 100000);
|
||||
QueryExpressionJunction<T, InstanceType> outsideOf(T lhs, T rhs) {
|
||||
expression = RangeExpression(lhs, rhs, within: false);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds an equality expression for foreign key columns to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected object's primary key is equal to [identifier].
|
||||
///
|
||||
/// This method may only be used on belongs-to relationships; i.e., those that have a [Relate] annotation.
|
||||
/// The type of [identifier] must match the primary key type of the selected object this expression is being applied to.
|
||||
///
|
||||
/// var q = new Query<Employee>()
|
||||
/// ..where((e) => e.manager).identifiedBy(5);
|
||||
QueryExpressionJunction<T, InstanceType> identifiedBy(dynamic identifier) {
|
||||
expression = ComparisonExpression(identifier, PredicateOperator.equalTo);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'null check' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is null.
|
||||
///
|
||||
/// This method can be applied to any property type.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var q = new Query<Employee>()
|
||||
/// ..where((e) => e.manager).isNull();
|
||||
QueryExpressionJunction<T, InstanceType> isNull() {
|
||||
expression = const NullCheckExpression();
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
|
||||
/// Adds a 'not null check' expression to a query.
|
||||
///
|
||||
/// A query will only return objects where the selected property is not null.
|
||||
///
|
||||
/// This method can be applied to any property type.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var q = new Query<Employee>()
|
||||
/// ..where((e) => e.manager).isNotNull();
|
||||
QueryExpressionJunction<T, InstanceType> isNotNull() {
|
||||
expression = const NullCheckExpression(shouldBeNull: false);
|
||||
|
||||
return _createJunction();
|
||||
}
|
||||
}
|
191
packages/database/lib/src/query/mixin.dart
Normal file
191
packages/database/lib/src/query/mixin.dart
Normal file
|
@ -0,0 +1,191 @@
|
|||
import 'package:protevus_database/src/managed/backing.dart';
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
import 'package:protevus_database/src/query/page.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
import 'package:protevus_database/src/query/sort_descriptor.dart';
|
||||
|
||||
mixin QueryMixin<InstanceType extends ManagedObject>
|
||||
implements Query<InstanceType> {
|
||||
@override
|
||||
int offset = 0;
|
||||
|
||||
@override
|
||||
int fetchLimit = 0;
|
||||
|
||||
@override
|
||||
int timeoutInSeconds = 30;
|
||||
|
||||
@override
|
||||
bool canModifyAllInstances = false;
|
||||
|
||||
@override
|
||||
Map<String, dynamic>? valueMap;
|
||||
|
||||
@override
|
||||
QueryPredicate? predicate;
|
||||
|
||||
@override
|
||||
QuerySortPredicate? sortPredicate;
|
||||
|
||||
QueryPage? pageDescriptor;
|
||||
final List<QuerySortDescriptor> sortDescriptors = <QuerySortDescriptor>[];
|
||||
final Map<ManagedRelationshipDescription, Query> subQueries = {};
|
||||
|
||||
QueryMixin? _parentQuery;
|
||||
List<QueryExpression<dynamic, dynamic>> expressions = [];
|
||||
InstanceType? _valueObject;
|
||||
|
||||
List<KeyPath>? _propertiesToFetch;
|
||||
|
||||
List<KeyPath> get propertiesToFetch =>
|
||||
_propertiesToFetch ??
|
||||
entity.defaultProperties!
|
||||
.map((k) => KeyPath(entity.properties[k]))
|
||||
.toList();
|
||||
|
||||
@override
|
||||
InstanceType get values {
|
||||
if (_valueObject == null) {
|
||||
_valueObject = entity.instanceOf() as InstanceType?;
|
||||
_valueObject!.backing = ManagedBuilderBacking.from(
|
||||
_valueObject!.entity,
|
||||
_valueObject!.backing,
|
||||
);
|
||||
}
|
||||
return _valueObject!;
|
||||
}
|
||||
|
||||
@override
|
||||
set values(InstanceType? obj) {
|
||||
if (obj == null) {
|
||||
_valueObject = null;
|
||||
return;
|
||||
}
|
||||
|
||||
_valueObject = entity.instanceOf(
|
||||
backing: ManagedBuilderBacking.from(entity, obj.backing),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
QueryExpression<T, InstanceType> where<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
) {
|
||||
final properties = entity.identifyProperties(propertyIdentifier);
|
||||
if (properties.length != 1) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Must reference a single property only.",
|
||||
);
|
||||
}
|
||||
|
||||
final expr = QueryExpression<T, InstanceType>(properties.first);
|
||||
expressions.add(expr);
|
||||
return expr;
|
||||
}
|
||||
|
||||
@override
|
||||
Query<T> join<T extends ManagedObject>({
|
||||
T? Function(InstanceType x)? object,
|
||||
ManagedSet<T>? Function(InstanceType x)? set,
|
||||
}) {
|
||||
final relationship = object ?? set!;
|
||||
final desc = entity.identifyRelationship(relationship);
|
||||
|
||||
return _createSubquery<T>(desc);
|
||||
}
|
||||
|
||||
@override
|
||||
void pageBy<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
QuerySortOrder order, {
|
||||
T? boundingValue,
|
||||
}) {
|
||||
final attribute = entity.identifyAttribute(propertyIdentifier);
|
||||
pageDescriptor =
|
||||
QueryPage(order, attribute.name, boundingValue: boundingValue);
|
||||
}
|
||||
|
||||
@override
|
||||
void sortBy<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
QuerySortOrder order,
|
||||
) {
|
||||
final attribute = entity.identifyAttribute(propertyIdentifier);
|
||||
|
||||
sortDescriptors.add(QuerySortDescriptor(attribute.name, order));
|
||||
}
|
||||
|
||||
@override
|
||||
void returningProperties(
|
||||
List<dynamic> Function(InstanceType x) propertyIdentifiers,
|
||||
) {
|
||||
final properties = entity.identifyProperties(propertyIdentifiers);
|
||||
|
||||
if (properties.any(
|
||||
(kp) => kp.path.any(
|
||||
(p) =>
|
||||
p is ManagedRelationshipDescription &&
|
||||
p.relationshipType != ManagedRelationshipType.belongsTo,
|
||||
),
|
||||
)) {
|
||||
throw ArgumentError(
|
||||
"Invalid property selector. Cannot select has-many or has-one relationship properties. Use join instead.",
|
||||
);
|
||||
}
|
||||
|
||||
_propertiesToFetch = entity.identifyProperties(propertyIdentifiers);
|
||||
}
|
||||
|
||||
void validateInput(Validating op) {
|
||||
if (valueMap == null) {
|
||||
if (op == Validating.insert) {
|
||||
values.willInsert();
|
||||
} else if (op == Validating.update) {
|
||||
values.willUpdate();
|
||||
}
|
||||
|
||||
final ctx = values.validate(forEvent: op);
|
||||
if (!ctx.isValid) {
|
||||
throw ValidationException(ctx.errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Query<T> _createSubquery<T extends ManagedObject>(
|
||||
ManagedRelationshipDescription fromRelationship,
|
||||
) {
|
||||
if (subQueries.containsKey(fromRelationship)) {
|
||||
throw StateError(
|
||||
"Invalid query. Cannot join same property more than once.",
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we don't cyclically join
|
||||
var parent = _parentQuery;
|
||||
while (parent != null) {
|
||||
if (parent.subQueries.containsKey(fromRelationship.inverse)) {
|
||||
final validJoins = fromRelationship.entity.relationships.values
|
||||
.where((r) => !identical(r, fromRelationship))
|
||||
.map((r) => "'${r!.name}'")
|
||||
.join(", ");
|
||||
|
||||
throw StateError(
|
||||
"Invalid query construction. This query joins '${fromRelationship.entity.tableName}' "
|
||||
"with '${fromRelationship.inverse!.entity.tableName}' on property '${fromRelationship.name}'. "
|
||||
"However, '${fromRelationship.inverse!.entity.tableName}' "
|
||||
"has also joined '${fromRelationship.entity.tableName}' on this property's inverse "
|
||||
"'${fromRelationship.inverse!.name}' earlier in the 'Query'. "
|
||||
"Perhaps you meant to join on another property, such as: $validJoins?");
|
||||
}
|
||||
|
||||
parent = parent._parentQuery;
|
||||
}
|
||||
|
||||
final subquery = Query<T>(context);
|
||||
(subquery as QueryMixin)._parentQuery = this;
|
||||
subQueries[fromRelationship] = subquery;
|
||||
|
||||
return subquery;
|
||||
}
|
||||
}
|
40
packages/database/lib/src/query/page.dart
Normal file
40
packages/database/lib/src/query/page.dart
Normal file
|
@ -0,0 +1,40 @@
|
|||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// A description of a page of results to be applied to a [Query].
|
||||
///
|
||||
/// [QueryPage]s are a convenient way of accomplishing paging through a large
|
||||
/// set of values. A page has the property to page on, the order in which the table is being
|
||||
/// paged and a value that indicates where in the ordered list of results the paging should start from.
|
||||
///
|
||||
/// Paging conceptually works by putting all of the rows in a table into an order. This order is determined by
|
||||
/// applying [order] to the values of [propertyName]. Once this order is defined, the position in that ordered list
|
||||
/// is found by going to the row (or rows) where [boundingValue] is eclipsed. That is, the point where row N
|
||||
/// has a value for [propertyName] that is less than or equal to [boundingValue] and row N + 1 has a value that is greater than
|
||||
/// [boundingValue]. The rows returned will start at row N + 1, ignoring rows 0 - N.
|
||||
///
|
||||
/// A query page should be used in conjunction with [Query.fetchLimit].
|
||||
class QueryPage {
|
||||
QueryPage(this.order, this.propertyName, {this.boundingValue});
|
||||
|
||||
/// The order in which rows should be in before the page of values is searched for.
|
||||
///
|
||||
/// The rows of a database table will be sorted according to this order on the column backing [propertyName] prior
|
||||
/// to this page being fetched.
|
||||
QuerySortOrder order;
|
||||
|
||||
/// The property of the model object to page on.
|
||||
///
|
||||
/// This property must have an inherent order, such as an [int] or [DateTime]. The database must be able to compare the values of this property using comparison operator '<' and '>'.
|
||||
String propertyName;
|
||||
|
||||
/// The point within an ordered set of result values in which rows will begin being fetched from.
|
||||
///
|
||||
/// After the table has been ordered by its [propertyName] and [order], the point in that ordered table
|
||||
/// is found where a row goes from being less than or equal to this value to greater than or equal to this value.
|
||||
/// Page results start at the row where this comparison changes.
|
||||
///
|
||||
/// Rows with a value equal to this value are not included in the data set. This value may be null. When this value is null,
|
||||
/// the [boundingValue] is set to be the just outside the first or last element of the ordered database table, depending on the direction.
|
||||
/// This allows for query pages that fetch the first or last page of elements when the starting/ending value is not known.
|
||||
dynamic boundingValue;
|
||||
}
|
203
packages/database/lib/src/query/predicate.dart
Normal file
203
packages/database/lib/src/query/predicate.dart
Normal file
|
@ -0,0 +1,203 @@
|
|||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// A predicate contains instructions for filtering rows when performing a [Query].
|
||||
///
|
||||
/// Predicates currently are the WHERE clause in a SQL statement and are used verbatim
|
||||
/// by the [PersistentStore]. In general, you should use [Query.where] instead of using this class directly, as [Query.where] will
|
||||
/// use the underlying [PersistentStore] to generate a [QueryPredicate] for you.
|
||||
///
|
||||
/// A predicate has a format and parameters. The format is the [String] that comes after WHERE in a SQL query. The format may
|
||||
/// have parameterized values, for which the corresponding value is in the [parameters] map. A parameter is prefixed with '@' in the format string. Currently,
|
||||
/// the format string's parameter syntax is defined by the [PersistentStore] it is used on. An example of that format:
|
||||
///
|
||||
/// var predicate = new QueryPredicate("x = @xValue", {"xValue" : 5});
|
||||
class QueryPredicate {
|
||||
/// Default constructor
|
||||
///
|
||||
/// The [format] and [parameters] of this predicate. [parameters] may be null.
|
||||
QueryPredicate(this.format, [this.parameters = const {}]);
|
||||
|
||||
/// Creates an empty predicate.
|
||||
///
|
||||
/// The format string is the empty string and parameters is the empty map.
|
||||
QueryPredicate.empty()
|
||||
: format = "",
|
||||
parameters = {};
|
||||
|
||||
/// Combines [predicates] with 'AND' keyword.
|
||||
///
|
||||
/// The [format] of the return value is produced by joining together each [predicates]
|
||||
/// [format] string with 'AND'. Each [parameters] from individual [predicates] is combined
|
||||
/// into the returned [parameters].
|
||||
///
|
||||
/// If there are duplicate parameter names in [predicates], they will be disambiguated by suffixing
|
||||
/// the parameter name in both [format] and [parameters] with a unique integer.
|
||||
///
|
||||
/// If [predicates] is null or empty, an empty predicate is returned. If [predicates] contains only
|
||||
/// one predicate, that predicate is returned.
|
||||
factory QueryPredicate.and(Iterable<QueryPredicate> predicates) {
|
||||
final predicateList = predicates.where((p) => p.format.isNotEmpty).toList();
|
||||
|
||||
if (predicateList.isEmpty) {
|
||||
return QueryPredicate.empty();
|
||||
}
|
||||
|
||||
if (predicateList.length == 1) {
|
||||
return predicateList.first;
|
||||
}
|
||||
|
||||
// If we have duplicate keys anywhere, we need to disambiguate them.
|
||||
int dupeCounter = 0;
|
||||
final allFormatStrings = [];
|
||||
final valueMap = <String, dynamic>{};
|
||||
for (final predicate in predicateList) {
|
||||
final duplicateKeys = predicate.parameters.keys
|
||||
.where((k) => valueMap.keys.contains(k))
|
||||
.toList();
|
||||
|
||||
if (duplicateKeys.isNotEmpty) {
|
||||
var fmt = predicate.format;
|
||||
final Map<String, String> dupeMap = {};
|
||||
for (final key in duplicateKeys) {
|
||||
final replacementKey = "$key$dupeCounter";
|
||||
fmt = fmt.replaceAll("@$key", "@$replacementKey");
|
||||
dupeMap[key] = replacementKey;
|
||||
dupeCounter++;
|
||||
}
|
||||
|
||||
allFormatStrings.add(fmt);
|
||||
predicate.parameters.forEach((key, value) {
|
||||
valueMap[dupeMap[key] ?? key] = value;
|
||||
});
|
||||
} else {
|
||||
allFormatStrings.add(predicate.format);
|
||||
valueMap.addAll(predicate.parameters);
|
||||
}
|
||||
}
|
||||
|
||||
final predicateFormat = "(${allFormatStrings.join(" AND ")})";
|
||||
return QueryPredicate(predicateFormat, valueMap);
|
||||
}
|
||||
|
||||
/// The string format of the this predicate.
|
||||
///
|
||||
/// This is the predicate text. Do not write dynamic values directly to the format string, instead, prefix an identifier with @
|
||||
/// and add that identifier to the [parameters] map.
|
||||
String format;
|
||||
|
||||
/// A map of values to replace in the format string at execution time.
|
||||
///
|
||||
/// Input values should not be in the format string, but instead provided in this map.
|
||||
/// Keys of this map will be searched for in the format string and be replaced by the value in this map.
|
||||
Map<String, dynamic> parameters;
|
||||
}
|
||||
|
||||
/// The operator in a comparison matcher.
|
||||
enum PredicateOperator {
|
||||
lessThan,
|
||||
greaterThan,
|
||||
notEqual,
|
||||
lessThanEqualTo,
|
||||
greaterThanEqualTo,
|
||||
equalTo
|
||||
}
|
||||
|
||||
class ComparisonExpression implements PredicateExpression {
|
||||
const ComparisonExpression(this.value, this.operator);
|
||||
|
||||
final dynamic value;
|
||||
final PredicateOperator operator;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return ComparisonExpression(value, inverseOperator);
|
||||
}
|
||||
|
||||
PredicateOperator get inverseOperator {
|
||||
switch (operator) {
|
||||
case PredicateOperator.lessThan:
|
||||
return PredicateOperator.greaterThanEqualTo;
|
||||
case PredicateOperator.greaterThan:
|
||||
return PredicateOperator.lessThanEqualTo;
|
||||
case PredicateOperator.notEqual:
|
||||
return PredicateOperator.equalTo;
|
||||
case PredicateOperator.lessThanEqualTo:
|
||||
return PredicateOperator.greaterThan;
|
||||
case PredicateOperator.greaterThanEqualTo:
|
||||
return PredicateOperator.lessThan;
|
||||
case PredicateOperator.equalTo:
|
||||
return PredicateOperator.notEqual;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The operator in a string matcher.
|
||||
enum PredicateStringOperator { beginsWith, contains, endsWith, equals }
|
||||
|
||||
abstract class PredicateExpression {
|
||||
PredicateExpression get inverse;
|
||||
}
|
||||
|
||||
class RangeExpression implements PredicateExpression {
|
||||
const RangeExpression(this.lhs, this.rhs, {this.within = true});
|
||||
|
||||
final bool within;
|
||||
final dynamic lhs;
|
||||
final dynamic rhs;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return RangeExpression(lhs, rhs, within: !within);
|
||||
}
|
||||
}
|
||||
|
||||
class NullCheckExpression implements PredicateExpression {
|
||||
const NullCheckExpression({this.shouldBeNull = true});
|
||||
|
||||
final bool shouldBeNull;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return NullCheckExpression(shouldBeNull: !shouldBeNull);
|
||||
}
|
||||
}
|
||||
|
||||
class SetMembershipExpression implements PredicateExpression {
|
||||
const SetMembershipExpression(this.values, {this.within = true});
|
||||
|
||||
final List<dynamic> values;
|
||||
final bool within;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return SetMembershipExpression(values, within: !within);
|
||||
}
|
||||
}
|
||||
|
||||
class StringExpression implements PredicateExpression {
|
||||
const StringExpression(
|
||||
this.value,
|
||||
this.operator, {
|
||||
this.caseSensitive = true,
|
||||
this.invertOperator = false,
|
||||
this.allowSpecialCharacters = true,
|
||||
});
|
||||
|
||||
final PredicateStringOperator operator;
|
||||
final bool invertOperator;
|
||||
final bool caseSensitive;
|
||||
final bool allowSpecialCharacters;
|
||||
final String value;
|
||||
|
||||
@override
|
||||
PredicateExpression get inverse {
|
||||
return StringExpression(
|
||||
value,
|
||||
operator,
|
||||
caseSensitive: caseSensitive,
|
||||
invertOperator: !invertOperator,
|
||||
allowSpecialCharacters: allowSpecialCharacters,
|
||||
);
|
||||
}
|
||||
}
|
425
packages/database/lib/src/query/query.dart
Normal file
425
packages/database/lib/src/query/query.dart
Normal file
|
@ -0,0 +1,425 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/query/error.dart';
|
||||
import 'package:protevus_database/src/query/matcher_expression.dart';
|
||||
import 'package:protevus_database/src/query/predicate.dart';
|
||||
import 'package:protevus_database/src/query/reduce.dart';
|
||||
import 'package:protevus_database/src/query/sort_predicate.dart';
|
||||
|
||||
export 'error.dart';
|
||||
export 'matcher_expression.dart';
|
||||
export 'mixin.dart';
|
||||
export 'predicate.dart';
|
||||
export 'reduce.dart';
|
||||
export 'sort_predicate.dart';
|
||||
|
||||
/// An object for configuring and executing a database query.
|
||||
///
|
||||
/// Queries are used to fetch, update, insert, delete and count [InstanceType]s in a database.
|
||||
/// [InstanceType] must be a [ManagedObject].
|
||||
///
|
||||
/// final query = Query<Employee>()
|
||||
/// ..where((e) => e.salary).greaterThan(50000);
|
||||
/// final employees = await query.fetch();
|
||||
abstract class Query<InstanceType extends ManagedObject> {
|
||||
/// Creates a new [Query].
|
||||
///
|
||||
/// The query will be sent to the database described by [context].
|
||||
/// For insert or update queries, you may provide [values] through this constructor
|
||||
/// or set the field of the same name later. If set in the constructor,
|
||||
/// [InstanceType] is inferred.
|
||||
factory Query(ManagedContext context, {InstanceType? values}) {
|
||||
final entity = context.dataModel!.tryEntityForType(InstanceType);
|
||||
if (entity == null) {
|
||||
throw ArgumentError(
|
||||
"Invalid context. The data model of 'context' does not contain '$InstanceType'.",
|
||||
);
|
||||
}
|
||||
|
||||
return context.persistentStore.newQuery<InstanceType>(
|
||||
context,
|
||||
entity,
|
||||
values: values,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a new [Query] without a static type.
|
||||
///
|
||||
/// This method is used when generating queries dynamically from runtime values,
|
||||
/// where the static type argument cannot be defined. Behaves just like the unnamed constructor.
|
||||
///
|
||||
/// If [entity] is not in [context]'s [ManagedContext.dataModel], throws a internal failure [QueryException].
|
||||
factory Query.forEntity(ManagedEntity entity, ManagedContext context) {
|
||||
if (!context.dataModel!.entities.any((e) => identical(entity, e))) {
|
||||
throw StateError(
|
||||
"Invalid query construction. Entity for '${entity.tableName}' is from different context than specified for query.",
|
||||
);
|
||||
}
|
||||
|
||||
return context.persistentStore.newQuery<InstanceType>(context, entity);
|
||||
}
|
||||
|
||||
/// Inserts a single [object] into the database managed by [context].
|
||||
///
|
||||
/// This is equivalent to creating a [Query], assigning [object] to [values], and invoking [insert].
|
||||
static Future<T> insertObject<T extends ManagedObject>(
|
||||
ManagedContext context,
|
||||
T object,
|
||||
) {
|
||||
return context.insertObject(object);
|
||||
}
|
||||
|
||||
/// Inserts each object in [objects] into the database managed by [context] in a single transaction.
|
||||
///
|
||||
/// This currently has no Query instance equivalent
|
||||
static Future<List<T>> insertObjects<T extends ManagedObject>(
|
||||
ManagedContext context,
|
||||
List<T> objects,
|
||||
) async {
|
||||
return context.insertObjects(objects);
|
||||
}
|
||||
|
||||
/// Configures this instance to fetch a relationship property identified by [object] or [set].
|
||||
///
|
||||
/// By default, objects returned by [Query.fetch] do not have their relationship properties populated. (In other words,
|
||||
/// [ManagedObject] and [ManagedSet] properties are null.) This method configures this instance to conduct a SQL join,
|
||||
/// allowing it to fetch relationship properties for the returned instances.
|
||||
///
|
||||
/// Consider a [ManagedObject] subclass with the following relationship properties as an example:
|
||||
///
|
||||
/// class User extends ManagedObject<_User> implements _User {}
|
||||
/// class _User {
|
||||
/// Profile profile;
|
||||
/// ManagedSet<Note> notes;
|
||||
/// }
|
||||
///
|
||||
/// To fetch an object and one of its has-one properties, use the [object] closure:
|
||||
///
|
||||
/// var query = Query<User>()
|
||||
/// ..join(object: (u) => u.profile);
|
||||
///
|
||||
/// To fetch an object and its has-many properties, use the [set] closure:
|
||||
///
|
||||
/// var query = Query<User>()
|
||||
/// ..join(set: (u) => u.notes);
|
||||
///
|
||||
/// Both [object] and [set] are passed an empty instance of the type being queried. [object] must return a has-one property (a [ManagedObject] subclass)
|
||||
/// of the object it is passed. [set] must return a has-many property (a [ManagedSet]) of the object it is passed.
|
||||
///
|
||||
/// Multiple relationship properties can be included by invoking this method multiple times with different properties, e.g.:
|
||||
///
|
||||
/// var query = Query<User>()
|
||||
/// ..join(object: (u) => u.profile)
|
||||
/// ..join(set: (u) => u.notes);
|
||||
///
|
||||
/// This method also returns a new instance of [Query], where [InstanceType] is is the type of the relationship property. This can be used
|
||||
/// to configure which properties are returned for the related objects and to filter a [ManagedSet] relationship property. For example:
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var subquery = query.join(set: (u) => u.notes)
|
||||
/// ..where.dateCreatedAt = whereGreaterThan(someDate);
|
||||
///
|
||||
/// This mechanism only works on [fetch] and [fetchOne] execution methods. You *must not* execute a subquery created by this method.
|
||||
Query<T> join<T extends ManagedObject>({
|
||||
T? Function(InstanceType x)? object,
|
||||
ManagedSet<T>? Function(InstanceType x)? set,
|
||||
});
|
||||
|
||||
/// Configures this instance to fetch a section of a larger result set.
|
||||
///
|
||||
/// This method provides an effective mechanism for paging a result set by ordering rows
|
||||
/// by some property, offsetting into that ordered set and returning rows starting from that offset.
|
||||
/// The [fetchLimit] of this instance must also be configured to limit the size of the page.
|
||||
///
|
||||
/// The property that determines order is identified by [propertyIdentifier]. This closure must
|
||||
/// return a property of of [InstanceType]. The order is determined by [order]. When fetching
|
||||
/// the 'first' page of results, no value is passed for [boundingValue]. As later pages are fetched,
|
||||
/// the value of the paging property for the last returned object in the previous result set is used
|
||||
/// as [boundingValue]. For example:
|
||||
///
|
||||
/// var recentHireQuery = Query<Employee>()
|
||||
/// ..pageBy((e) => e.hireDate, QuerySortOrder.descending);
|
||||
/// var recentHires = await recentHireQuery.fetch();
|
||||
///
|
||||
/// var nextRecentHireQuery = Query<Employee>()
|
||||
/// ..pageBy((e) => e.hireDate, QuerySortOrder.descending,
|
||||
/// boundingValue: recentHires.last.hireDate);
|
||||
///
|
||||
/// Note that internally, [pageBy] adds a matcher to [where] and adds a high-priority [sortBy].
|
||||
/// Adding multiple [pageBy]s to an instance has undefined behavior.
|
||||
void pageBy<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
QuerySortOrder order, {
|
||||
T? boundingValue,
|
||||
});
|
||||
|
||||
/// Configures this instance to sort its results by some property and order.
|
||||
///
|
||||
/// This method will have the database perform a sort by some property identified by [propertyIdentifier].
|
||||
/// [propertyIdentifier] must return a scalar property of [InstanceType] that can be compared. The [order]
|
||||
/// indicates the order the returned rows will be in. Multiple [sortBy]s may be invoked on an instance;
|
||||
/// the order in which they are added indicates sort precedence. Example:
|
||||
///
|
||||
/// var query = Query<Employee>()
|
||||
/// ..sortBy((e) => e.name, QuerySortOrder.ascending);
|
||||
void sortBy<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
QuerySortOrder order,
|
||||
);
|
||||
|
||||
/// The [ManagedEntity] of the [InstanceType].
|
||||
ManagedEntity get entity;
|
||||
|
||||
/// The [ManagedContext] this query will be executed on.
|
||||
ManagedContext get context;
|
||||
|
||||
/// Returns a new object that can execute functions like sum, average, maximum, etc.
|
||||
///
|
||||
/// The methods of this object will execute an aggregate function on the database table.
|
||||
/// For example, this property can be used to find the average age of all users.
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var averageAge = await query.reduce.average((user) => user.age);
|
||||
///
|
||||
/// Any where clauses established by [where] or [predicate] will impact the rows evaluated
|
||||
/// and therefore the value returned from this object's instance methods.
|
||||
///
|
||||
/// Always returns a new instance of [QueryReduceOperation]. The returned object is permanently
|
||||
/// associated with this instance. Any changes to this instance (i.e., modifying [where]) will impact the
|
||||
/// result.
|
||||
QueryReduceOperation<InstanceType> get reduce;
|
||||
|
||||
/// Selects a property from the object being queried to add a filtering expression.
|
||||
///
|
||||
/// You use this property to add filtering expression to a query. The expressions are added to the SQL WHERE clause
|
||||
/// of the generated query.
|
||||
///
|
||||
/// You provide a closure for [propertyIdentifier] that returns a property of its argument. Its argument is always
|
||||
/// an empty instance of the object being queried. You invoke methods like [QueryExpression.lessThan] on the
|
||||
/// object returned from this method to add an expression to this query.
|
||||
///
|
||||
/// final query = Query<Employee>()
|
||||
/// ..where((e) => e.name).equalTo("Bob");
|
||||
///
|
||||
/// You may select properties of relationships using this method.
|
||||
///
|
||||
/// final query = Query<Employee>()
|
||||
/// ..where((e) => e.manager.name).equalTo("Sally");
|
||||
///
|
||||
QueryExpression<T, InstanceType> where<T>(
|
||||
T Function(InstanceType x) propertyIdentifier,
|
||||
);
|
||||
|
||||
/// Confirms that a query has no predicate before executing it.
|
||||
///
|
||||
/// This is a safety measure for update and delete queries to prevent accidentally updating or deleting every row.
|
||||
/// This flag defaults to false, meaning that if this query is either an update or a delete, but contains no predicate,
|
||||
/// it will fail. If a query is meant to update or delete every row on a table, you may set this to true to allow this query to proceed.
|
||||
bool canModifyAllInstances = false;
|
||||
|
||||
/// Number of seconds before a Query times out.
|
||||
///
|
||||
/// A Query will fail and throw a [QueryException] if [timeoutInSeconds] seconds elapse before the query completes.
|
||||
/// Defaults to 30 seconds.
|
||||
int timeoutInSeconds = 30;
|
||||
|
||||
/// Limits the number of objects returned from the Query.
|
||||
///
|
||||
/// Defaults to 0. When zero, there is no limit to the number of objects returned from the Query.
|
||||
/// This value should be set when using [pageBy] to limit the page size.
|
||||
int fetchLimit = 0;
|
||||
|
||||
/// Offsets the rows returned.
|
||||
///
|
||||
/// The set of rows returned will exclude the first [offset] number of rows selected in the query. Do not
|
||||
/// set this property when using [pageBy].
|
||||
int offset = 0;
|
||||
|
||||
/// A predicate for filtering the result or operation set.
|
||||
///
|
||||
/// A predicate will identify the rows being accessed, see [QueryPredicate] for more details. Prefer to use
|
||||
/// [where] instead of this property directly.
|
||||
QueryPredicate? predicate;
|
||||
|
||||
/// A predicate for sorting the result.
|
||||
///
|
||||
/// A predicate will identify the rows being accessed, see [QuerySortPredicate] for more details. Prefer to use
|
||||
/// [sortBy] instead of this property directly.
|
||||
QuerySortPredicate? sortPredicate;
|
||||
|
||||
/// Values to be used when inserting or updating an object.
|
||||
///
|
||||
/// This method is an unsafe version of [values]. Prefer to use [values] instead.
|
||||
/// Keys in this map must be the name of a property of [InstanceType], otherwise an exception
|
||||
/// is thrown. Values provided in this map are not run through any [Validate] annotations
|
||||
/// declared by the [InstanceType].
|
||||
///
|
||||
/// Do not set this property and [values] on the same query. If both this property and [values] are set,
|
||||
/// the behavior is undefined.
|
||||
Map<String, dynamic>? valueMap;
|
||||
|
||||
/// Values to be sent to database during an [update] or [insert] query.
|
||||
///
|
||||
/// You set values for the properties of this object to insert a row or update one or more rows.
|
||||
/// This property is the same type as the type being inserted or updated. [values] is empty (but not null)
|
||||
/// when a [Query] is first created, therefore, you do not have to assign an instance to it and may set
|
||||
/// values for its properties immediately:
|
||||
///
|
||||
/// var q = Query<User>()
|
||||
/// ..values.name = 'Joe'
|
||||
/// ..values.job = 'programmer';
|
||||
/// await q.insert();
|
||||
///
|
||||
/// You may only set values for properties that are backed by a column. This includes most properties, except
|
||||
/// all [ManagedSet] properties and [ManagedObject] properties that do not have a [Relate] annotation. If you attempt
|
||||
/// to set a property that isn't allowed on [values], an error is thrown.
|
||||
///
|
||||
/// If a property of [values] is a [ManagedObject] with a [Relate] annotation,
|
||||
/// you may provide a value for its primary key property. This value will be
|
||||
/// stored in the foreign key column that backs the property. You may set
|
||||
/// properties of this type immediately, without having to create an instance
|
||||
/// of the related type:
|
||||
///
|
||||
/// // Assumes that Employee is declared with the following property:
|
||||
/// // @Relate(#employees)
|
||||
/// // Manager manager;
|
||||
///
|
||||
/// final q = Query<Employee>()
|
||||
/// ..values.name = "Sally"
|
||||
/// ..values.manager.id = 10;
|
||||
/// await q.insert();
|
||||
///
|
||||
/// WARNING: You may replace this property with a new instance of [InstanceType].
|
||||
/// When doing so, a copy of the object is created and assigned to this property.
|
||||
///
|
||||
/// final o = SomeObject()
|
||||
/// ..id = 1;
|
||||
/// final q = Query<SomeObject>()
|
||||
/// ..values = o;
|
||||
///
|
||||
/// o.id = 2;
|
||||
/// assert(q.values.id == 1); // true
|
||||
///
|
||||
late InstanceType values;
|
||||
|
||||
/// Configures the list of properties to be fetched for [InstanceType].
|
||||
///
|
||||
/// This method configures which properties will be populated for [InstanceType] when returned
|
||||
/// from this query. This impacts all query execution methods that return [InstanceType] or [List] of [InstanceType].
|
||||
///
|
||||
/// The following example would configure this instance to fetch the 'id' and 'name' for each returned 'Employee':
|
||||
///
|
||||
/// var q = Query<Employee>()
|
||||
/// ..returningProperties((employee) => [employee.id, employee.name]);
|
||||
///
|
||||
/// Note that if the primary key property of an object is omitted from this list, it will be added when this
|
||||
/// instance executes. If the primary key value should not be sent back as part of an API response,
|
||||
/// it can be stripped from the returned object(s) with [ManagedObject.removePropertyFromBackingMap].
|
||||
///
|
||||
/// If this method is not invoked, the properties defined by [ManagedEntity.defaultProperties] are returned.
|
||||
void returningProperties(
|
||||
List<dynamic> Function(InstanceType x) propertyIdentifiers,
|
||||
);
|
||||
|
||||
/// Inserts an [InstanceType] into the underlying database.
|
||||
///
|
||||
/// The [Query] must have its [values] or [valueMap] property set. This operation will
|
||||
/// insert a row with the data supplied in those fields to the database in [context]. The return value is
|
||||
/// a [Future] that completes with the newly inserted [InstanceType]. Example:
|
||||
///
|
||||
/// var q = Query<User>()
|
||||
/// ..values.name = "Joe";
|
||||
/// var newUser = await q.insert();
|
||||
///
|
||||
/// If the [InstanceType] has properties with [Validate] metadata, those validations
|
||||
/// will be executed prior to sending the query to the database.
|
||||
///
|
||||
/// The method guaranties that exactly one row will be inserted and returned
|
||||
/// or an exception will be thrown and the row will not be written to the database.
|
||||
Future<InstanceType> insert();
|
||||
|
||||
/// Inserts an [InstanceType]s into the underlying database.
|
||||
///
|
||||
/// The [Query] must not have its [values] nor [valueMap] property set. This
|
||||
/// operation will insert a row for each item in [objects] to the database in
|
||||
/// [context]. The return value is a [Future] that completes with the newly
|
||||
/// inserted [InstanceType]s. Example:
|
||||
///
|
||||
/// final users = [
|
||||
/// User()..email = 'user1@example.dev',
|
||||
/// User()..email = 'user2@example.dev',
|
||||
/// ];
|
||||
/// final q = Query<User>();
|
||||
/// var newUsers = await q.insertMany(users);
|
||||
///
|
||||
/// If the [InstanceType] has properties with [Validate] metadata, those
|
||||
/// validations will be executed prior to sending the query to the database.
|
||||
///
|
||||
/// The method guaranties that either all rows will be inserted and returned
|
||||
/// or exception will be thrown and non of the rows will be written to the database.
|
||||
Future<List<InstanceType>> insertMany(List<InstanceType> objects);
|
||||
|
||||
/// Updates [InstanceType]s in the underlying database.
|
||||
///
|
||||
/// The [Query] must have its [values] or [valueMap] property set and should likely have its [predicate] or [where] set as well. This operation will
|
||||
/// update each row that matches the conditions in [predicate]/[where] with the values from [values] or [valueMap]. If no [where] or [predicate] is set,
|
||||
/// this method will throw an exception by default, assuming that you don't typically want to update every row in a database table. To specify otherwise,
|
||||
/// set [canModifyAllInstances] to true.
|
||||
/// The return value is a [Future] that completes with the any updated [InstanceType]s. Example:
|
||||
///
|
||||
/// var q = Query<User>()
|
||||
/// ..where.name = "Fred"
|
||||
/// ..values.name = "Joe";
|
||||
/// var usersNamedFredNowNamedJoe = await q.update();
|
||||
///
|
||||
/// If the [InstanceType] has properties with [Validate] metadata, those validations
|
||||
/// will be executed prior to sending the query to the database.
|
||||
Future<List<InstanceType>> update();
|
||||
|
||||
/// Updates an [InstanceType] in the underlying database.
|
||||
///
|
||||
/// This method works the same as [update], except it may only update one row in the underlying database. If this method
|
||||
/// ends up modifying multiple rows, an exception is thrown.
|
||||
///
|
||||
/// If the [InstanceType] has properties with [Validate] metadata, those validations
|
||||
/// will be executed prior to sending the query to the database.
|
||||
Future<InstanceType?> updateOne();
|
||||
|
||||
/// Fetches [InstanceType]s from the database.
|
||||
///
|
||||
/// This operation will return all [InstanceType]s from the database, filtered by [predicate]/[where]. Example:
|
||||
///
|
||||
/// var q = Query<User>();
|
||||
/// var allUsers = q.fetch();
|
||||
///
|
||||
Future<List<InstanceType>> fetch();
|
||||
|
||||
/// Fetches a single [InstanceType] from the database.
|
||||
///
|
||||
/// This method behaves the same as [fetch], but limits the results to a single object.
|
||||
Future<InstanceType?> fetchOne();
|
||||
|
||||
/// Deletes [InstanceType]s from the underlying database.
|
||||
///
|
||||
/// This method will delete rows identified by [predicate]/[where]. If no [where] or [predicate] is set,
|
||||
/// this method will throw an exception by default, assuming that you don't typically want to delete every row in a database table. To specify otherwise,
|
||||
/// set [canModifyAllInstances] to true.
|
||||
///
|
||||
/// This method will return the number of rows deleted.
|
||||
/// Example:
|
||||
///
|
||||
/// var q = Query<User>()
|
||||
/// ..where.id = whereEqualTo(1);
|
||||
/// var deletedCount = await q.delete();
|
||||
Future<int> delete();
|
||||
}
|
||||
|
||||
/// Order value for [Query.pageBy] and [Query.sortBy].
|
||||
enum QuerySortOrder {
|
||||
/// Ascending order. Example: 1, 2, 3, 4, ...
|
||||
ascending,
|
||||
|
||||
/// Descending order. Example: 4, 3, 2, 1, ...
|
||||
descending
|
||||
}
|
62
packages/database/lib/src/query/reduce.dart
Normal file
62
packages/database/lib/src/query/reduce.dart
Normal file
|
@ -0,0 +1,62 @@
|
|||
import 'dart:async';
|
||||
import 'package:protevus_database/src/managed/object.dart';
|
||||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// Executes aggregate functions like average, count, sum, etc.
|
||||
///
|
||||
/// See instance methods for available aggregate functions.
|
||||
///
|
||||
/// See [Query.reduce] for more details on usage.
|
||||
abstract class QueryReduceOperation<T extends ManagedObject> {
|
||||
/// Computes the average of some [ManagedObject] property.
|
||||
///
|
||||
/// [selector] identifies the property being averaged, e.g.
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var averageAge = await query.reduce.average((user) => user.age);
|
||||
///
|
||||
/// The property must be an attribute and its type must be an [num], i.e. [int] or [double].
|
||||
Future<double?> average(num? Function(T object) selector);
|
||||
|
||||
/// Counts the number of [ManagedObject] instances in the database.
|
||||
///
|
||||
/// Note: this can be an expensive query. Consult the documentation
|
||||
/// for the underlying database.
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var totalUsers = await query.reduce.count();
|
||||
///
|
||||
Future<int> count();
|
||||
|
||||
/// Finds the maximum of some [ManagedObject] property.
|
||||
///
|
||||
/// [selector] identifies the property being evaluated, e.g.
|
||||
///
|
||||
/// var query = Query<User>();
|
||||
/// var oldestUser = await query.reduce.maximum((user) => user.age);
|
||||
///
|
||||
/// The property must be an attribute and its type must be [String], [int], [double], or [DateTime].
|
||||
Future<U?> maximum<U>(U? Function(T object) selector);
|
||||
|
||||
/// Finds the minimum of some [ManagedObject] property.
|
||||
///
|
||||
/// [selector] identifies the property being evaluated, e.g.
|
||||
///
|
||||
/// var query = new Query<User>();
|
||||
/// var youngestUser = await query.reduce.minimum((user) => user.age);
|
||||
///
|
||||
/// The property must be an attribute and its type must be [String], [int], [double], or [DateTime].
|
||||
Future<U?> minimum<U>(U? Function(T object) selector);
|
||||
|
||||
/// Finds the sum of some [ManagedObject] property.
|
||||
///
|
||||
/// [selector] identifies the property being evaluated, e.g.
|
||||
///
|
||||
/// var query = new Query<User>();
|
||||
/// var yearsLivesByAllUsers = await query.reduce.sum((user) => user.age);
|
||||
///
|
||||
/// The property must be an attribute and its type must be an [num], i.e. [int] or [double].
|
||||
Future<U?> sum<U extends num>(U? Function(T object) selector);
|
||||
}
|
16
packages/database/lib/src/query/sort_descriptor.dart
Normal file
16
packages/database/lib/src/query/sort_descriptor.dart
Normal file
|
@ -0,0 +1,16 @@
|
|||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// The order in which a collection of objects should be sorted when returned from a database.
|
||||
///
|
||||
/// See [Query.sortBy] and [Query.pageBy] for more details.
|
||||
class QuerySortDescriptor {
|
||||
QuerySortDescriptor(this.key, this.order);
|
||||
|
||||
/// The name of a property to sort by.
|
||||
String key;
|
||||
|
||||
/// The order in which values should be sorted.
|
||||
///
|
||||
/// See [QuerySortOrder] for possible values.
|
||||
QuerySortOrder order;
|
||||
}
|
17
packages/database/lib/src/query/sort_predicate.dart
Normal file
17
packages/database/lib/src/query/sort_predicate.dart
Normal file
|
@ -0,0 +1,17 @@
|
|||
import 'package:protevus_database/src/query/query.dart';
|
||||
|
||||
/// The order in which a collection of objects should be sorted when returned from a database.
|
||||
class QuerySortPredicate {
|
||||
QuerySortPredicate(
|
||||
this.predicate,
|
||||
this.order,
|
||||
);
|
||||
|
||||
/// The name of a property to sort by.
|
||||
String predicate;
|
||||
|
||||
/// The order in which values should be sorted.
|
||||
///
|
||||
/// See [QuerySortOrder] for possible values.
|
||||
QuerySortOrder order;
|
||||
}
|
88
packages/database/lib/src/schema/migration.dart
Normal file
88
packages/database/lib/src/schema/migration.dart
Normal file
|
@ -0,0 +1,88 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
/// Thrown when [Migration] encounters an error.
|
||||
class MigrationException implements Exception {
|
||||
MigrationException(this.message);
|
||||
String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// The base class for migration instructions.
|
||||
///
|
||||
/// For each set of changes to a database, a subclass of [Migration] is created.
|
||||
/// Subclasses will override [upgrade] to make changes to the [Schema] which
|
||||
/// are translated into database operations to update a database's schema.
|
||||
abstract class Migration {
|
||||
/// The current state of the [Schema].
|
||||
///
|
||||
/// During migration, this value will be modified as [SchemaBuilder] operations
|
||||
/// are executed. See [SchemaBuilder].
|
||||
Schema get currentSchema => database.schema;
|
||||
|
||||
/// The [PersistentStore] that represents the database being migrated.
|
||||
PersistentStore? get store => database.store;
|
||||
|
||||
// This value is provided by the 'upgrade' tool and is derived from the filename.
|
||||
int? version;
|
||||
|
||||
/// Receiver for database altering operations.
|
||||
///
|
||||
/// Methods invoked on this instance - such as [SchemaBuilder.createTable] - will be validated
|
||||
/// and generate the appropriate SQL commands to apply to a database to alter its schema.
|
||||
late SchemaBuilder database;
|
||||
|
||||
/// Method invoked to upgrade a database to this migration version.
|
||||
///
|
||||
/// Subclasses will override this method and invoke methods on [database] to upgrade
|
||||
/// the database represented by [store].
|
||||
Future upgrade();
|
||||
|
||||
/// Method invoked to downgrade a database from this migration version.
|
||||
///
|
||||
/// Subclasses will override this method and invoke methods on [database] to downgrade
|
||||
/// the database represented by [store].
|
||||
Future downgrade();
|
||||
|
||||
/// Method invoked to seed a database's data after this migration version is upgraded to.
|
||||
///
|
||||
/// Subclasses will override this method and invoke query methods on [store] to add data
|
||||
/// to a database after this migration version is executed.
|
||||
Future seed();
|
||||
|
||||
static String sourceForSchemaUpgrade(
|
||||
Schema existingSchema,
|
||||
Schema newSchema,
|
||||
int? version, {
|
||||
List<String>? changeList,
|
||||
}) {
|
||||
final diff = existingSchema.differenceFrom(newSchema);
|
||||
final source =
|
||||
SchemaBuilder.fromDifference(null, diff, changeList: changeList)
|
||||
.commands
|
||||
.map((line) => "\t\t$line")
|
||||
.join("\n");
|
||||
|
||||
return """
|
||||
import 'dart:async';
|
||||
import 'package:protevus_database/prots_database.dart
|
||||
|
||||
class Migration$version extends Migration {
|
||||
@override
|
||||
Future upgrade() async {
|
||||
$source
|
||||
}
|
||||
|
||||
@override
|
||||
Future downgrade() async {}
|
||||
|
||||
@override
|
||||
Future seed() async {}
|
||||
}
|
||||
""";
|
||||
}
|
||||
}
|
231
packages/database/lib/src/schema/schema.dart
Normal file
231
packages/database/lib/src/schema/schema.dart
Normal file
|
@ -0,0 +1,231 @@
|
|||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/schema/schema_table.dart';
|
||||
|
||||
export 'migration.dart';
|
||||
export 'schema_builder.dart';
|
||||
export 'schema_column.dart';
|
||||
export 'schema_table.dart';
|
||||
|
||||
/// A portable representation of a database schema.
|
||||
///
|
||||
/// Instances of this type contain the database-only details of a [ManagedDataModel] and are typically
|
||||
/// instantiated from [ManagedDataModel]s. The purpose of this type is to have a portable, differentiable representation
|
||||
/// of an application's data model for use in external tooling.
|
||||
class Schema {
|
||||
/// Creates an instance of this type with a specific set of [tables].
|
||||
///
|
||||
/// Prefer to use [Schema.fromDataModel].
|
||||
Schema(List<SchemaTable> tables) : _tableStorage = tables;
|
||||
|
||||
/// Creates an instance of this type from [dataModel].
|
||||
///
|
||||
/// This is preferred method of creating an instance of this type. Each [ManagedEntity]
|
||||
/// in [dataModel] will correspond to a [SchemaTable] in [tables].
|
||||
Schema.fromDataModel(ManagedDataModel dataModel) {
|
||||
_tables = dataModel.entities.map((e) => SchemaTable.fromEntity(e)).toList();
|
||||
}
|
||||
|
||||
/// Creates a deep copy of [otherSchema].
|
||||
Schema.from(Schema otherSchema) {
|
||||
_tables =
|
||||
otherSchema.tables.map((table) => SchemaTable.from(table)).toList();
|
||||
}
|
||||
|
||||
/// Creates a instance of this type from [map].
|
||||
///
|
||||
/// [map] is typically created from [asMap].
|
||||
Schema.fromMap(Map<String, dynamic> map) {
|
||||
_tables = (map["tables"] as List<Map<String, dynamic>>)
|
||||
.map((t) => SchemaTable.fromMap(t))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Creates an empty schema.
|
||||
Schema.empty() {
|
||||
_tables = [];
|
||||
}
|
||||
|
||||
/// The tables in this database.
|
||||
///
|
||||
/// Returns an immutable list of tables in this schema.
|
||||
List<SchemaTable> get tables => List.unmodifiable(_tableStorage);
|
||||
|
||||
// Do not set this directly. Use _tables= instead.
|
||||
late List<SchemaTable> _tableStorage;
|
||||
|
||||
set _tables(List<SchemaTable> tables) {
|
||||
_tableStorage = tables;
|
||||
for (final t in _tableStorage) {
|
||||
t.schema = this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a table from [tables] by that table's name.
|
||||
///
|
||||
/// See [tableForName] for details.
|
||||
SchemaTable? operator [](String tableName) => tableForName(tableName);
|
||||
|
||||
/// The differences between two schemas.
|
||||
///
|
||||
/// In the return value, the receiver is the [SchemaDifference.expectedSchema]
|
||||
/// and [otherSchema] is [SchemaDifference.actualSchema].
|
||||
SchemaDifference differenceFrom(Schema otherSchema) {
|
||||
return SchemaDifference(this, otherSchema);
|
||||
}
|
||||
|
||||
/// Adds a table to this instance.
|
||||
///
|
||||
/// Sets [table]'s [SchemaTable.schema] to this instance.
|
||||
void addTable(SchemaTable table) {
|
||||
if (this[table.name!] != null) {
|
||||
throw SchemaException(
|
||||
"Table ${table.name} already exists and cannot be added.",
|
||||
);
|
||||
}
|
||||
|
||||
_tableStorage.add(table);
|
||||
table.schema = this;
|
||||
}
|
||||
|
||||
void replaceTable(SchemaTable existingTable, SchemaTable newTable) {
|
||||
if (!_tableStorage.contains(existingTable)) {
|
||||
throw SchemaException(
|
||||
"Table ${existingTable.name} does not exist and cannot be replaced.",
|
||||
);
|
||||
}
|
||||
|
||||
final index = _tableStorage.indexOf(existingTable);
|
||||
_tableStorage[index] = newTable;
|
||||
newTable.schema = this;
|
||||
existingTable.schema = null;
|
||||
}
|
||||
|
||||
void renameTable(SchemaTable table, String newName) {
|
||||
throw SchemaException("Renaming a table not yet implemented!");
|
||||
//
|
||||
// if (tableForName(newName) != null) {
|
||||
// throw new SchemaException("Table ${newName} already exist.");
|
||||
// }
|
||||
//
|
||||
// if (!tables.contains(table)) {
|
||||
// throw new SchemaException("Table ${table.name} does not exist in schema.");
|
||||
// }
|
||||
//
|
||||
// // Rename indices and constraints
|
||||
// table.name = newName;
|
||||
}
|
||||
|
||||
/// Removes a table from this instance.
|
||||
///
|
||||
/// [table] must be an instance in [tables] or an exception is thrown.
|
||||
/// Sets [table]'s [SchemaTable.schema] to null.
|
||||
void removeTable(SchemaTable table) {
|
||||
if (!tables.contains(table)) {
|
||||
throw SchemaException("Table ${table.name} does not exist in schema.");
|
||||
}
|
||||
table.schema = null;
|
||||
_tableStorage.remove(table);
|
||||
}
|
||||
|
||||
/// Returns a [SchemaTable] for [name].
|
||||
///
|
||||
/// [name] is case-insensitively compared to every [SchemaTable.name]
|
||||
/// in [tables]. If no table with this name exists, null is returned.
|
||||
///
|
||||
/// Note: tables are typically prefixed with an underscore when using
|
||||
/// Conduit naming conventions for [ManagedObject].
|
||||
SchemaTable? tableForName(String name) {
|
||||
final lowercaseName = name.toLowerCase();
|
||||
|
||||
return tables
|
||||
.firstWhereOrNull((t) => t.name!.toLowerCase() == lowercaseName);
|
||||
}
|
||||
|
||||
/// Emits this instance as a transportable [Map].
|
||||
Map<String, dynamic> asMap() {
|
||||
return {"tables": tables.map((t) => t.asMap()).toList()};
|
||||
}
|
||||
}
|
||||
|
||||
/// The difference between two compared [Schema]s.
|
||||
///
|
||||
/// This class is used for comparing schemas for validation and migration.
|
||||
class SchemaDifference {
|
||||
/// Creates a new instance that represents the difference between [expectedSchema] and [actualSchema].
|
||||
///
|
||||
SchemaDifference(this.expectedSchema, this.actualSchema) {
|
||||
for (final expectedTable in expectedSchema.tables) {
|
||||
final actualTable = actualSchema[expectedTable.name!];
|
||||
if (actualTable == null) {
|
||||
_differingTables.add(SchemaTableDifference(expectedTable, null));
|
||||
} else {
|
||||
final diff = expectedTable.differenceFrom(actualTable);
|
||||
if (diff.hasDifferences) {
|
||||
_differingTables.add(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_differingTables.addAll(
|
||||
actualSchema.tables
|
||||
.where((t) => expectedSchema[t.name!] == null)
|
||||
.map((unexpectedTable) {
|
||||
return SchemaTableDifference(null, unexpectedTable);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/// The 'expected' schema.
|
||||
final Schema expectedSchema;
|
||||
|
||||
/// The 'actual' schema.
|
||||
final Schema actualSchema;
|
||||
|
||||
/// Whether or not [expectedSchema] and [actualSchema] have differences.
|
||||
///
|
||||
/// If false, both [expectedSchema] and [actualSchema], their tables, and those tables' columns are identical.
|
||||
bool get hasDifferences => _differingTables.isNotEmpty;
|
||||
|
||||
/// Human-readable messages to describe differences between [expectedSchema] and [actualSchema].
|
||||
///
|
||||
/// Empty is [hasDifferences] is false.
|
||||
List<String> get errorMessages =>
|
||||
_differingTables.expand((diff) => diff.errorMessages).toList();
|
||||
|
||||
/// The differences, if any, between tables in [expectedSchema] and [actualSchema].
|
||||
List<SchemaTableDifference> get tableDifferences => _differingTables;
|
||||
|
||||
List<SchemaTable?> get tablesToAdd {
|
||||
return _differingTables
|
||||
.where((diff) => diff.expectedTable == null && diff.actualTable != null)
|
||||
.map((d) => d.actualTable)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<SchemaTable?> get tablesToDelete {
|
||||
return _differingTables
|
||||
.where((diff) => diff.expectedTable != null && diff.actualTable == null)
|
||||
.map((diff) => diff.expectedTable)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<SchemaTableDifference> get tablesToModify {
|
||||
return _differingTables
|
||||
.where((diff) => diff.expectedTable != null && diff.actualTable != null)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final List<SchemaTableDifference> _differingTables = [];
|
||||
}
|
||||
|
||||
/// Thrown when a [Schema] encounters an error.
|
||||
class SchemaException implements Exception {
|
||||
SchemaException(this.message);
|
||||
|
||||
String message;
|
||||
|
||||
@override
|
||||
String toString() => "Invalid schema. $message";
|
||||
}
|
540
packages/database/lib/src/schema/schema_builder.dart
Normal file
540
packages/database/lib/src/schema/schema_builder.dart
Normal file
|
@ -0,0 +1,540 @@
|
|||
import 'package:protevus_database/src/persistent_store/persistent_store.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
/*
|
||||
Tests for this class are spread out some. The testing concept used starts by understanding that
|
||||
that each method invoked on the builder (e.g. createTable, addColumn) adds a statement to [commands].
|
||||
A statement is either:
|
||||
|
||||
a) A Dart statement that replicate the command to build migration code
|
||||
b) A SQL command when running a migration
|
||||
|
||||
In effect, the generated Dart statement is the source code for the invoked method. Each method invoked on a
|
||||
builder is tested so that the generated Dart code is equivalent
|
||||
to the invocation. These tests are in generate_code_test.dart.
|
||||
|
||||
The code to ensure the generated SQL is accurate is in db/postgresql/schema_generator_sql_mapping_test.dart.
|
||||
|
||||
The logic that goes into testing that the commands generated to build a valid schema in an actual postgresql are in db/postgresql/migration_test.dart.
|
||||
*/
|
||||
|
||||
/// Generates SQL or Dart code that modifies a database schema.
|
||||
class SchemaBuilder {
|
||||
/// Creates a builder starting from an existing schema.
|
||||
///
|
||||
/// If [store] is null, this builder will emit [commands] that are Dart statements that replicate the methods invoked on this object.
|
||||
/// Otherwise, [commands] are SQL commands (for the database represented by [store]) that are equivalent to the method invoked on this object.
|
||||
SchemaBuilder(this.store, this.inputSchema, {this.isTemporary = false}) {
|
||||
schema = Schema.from(inputSchema);
|
||||
}
|
||||
|
||||
/// Creates a builder starting from the empty schema.
|
||||
///
|
||||
/// If [store] is null, this builder will emit [commands] that are Dart statements that replicate the methods invoked on this object.
|
||||
/// Otherwise, [commands] are SQL commands (for the database represented by [store]) that are equivalent to the method invoked on this object.
|
||||
SchemaBuilder.toSchema(
|
||||
PersistentStore? store,
|
||||
Schema targetSchema, {
|
||||
bool isTemporary = false,
|
||||
List<String>? changeList,
|
||||
}) : this.fromDifference(
|
||||
store,
|
||||
SchemaDifference(Schema.empty(), targetSchema),
|
||||
isTemporary: isTemporary,
|
||||
changeList: changeList,
|
||||
);
|
||||
|
||||
// Creates a builder
|
||||
SchemaBuilder.fromDifference(
|
||||
this.store,
|
||||
SchemaDifference difference, {
|
||||
this.isTemporary = false,
|
||||
List<String>? changeList,
|
||||
}) {
|
||||
schema = difference.expectedSchema;
|
||||
_generateSchemaCommands(
|
||||
difference,
|
||||
changeList: changeList,
|
||||
temporary: isTemporary,
|
||||
);
|
||||
}
|
||||
|
||||
/// The starting schema of this builder.
|
||||
late Schema inputSchema;
|
||||
|
||||
/// The resulting schema of this builder as operations are applied to it.
|
||||
late Schema schema;
|
||||
|
||||
/// The persistent store to validate and construct operations.
|
||||
///
|
||||
/// If this value is not-null, [commands] is a list of SQL commands for the underlying database that change the schema in response to
|
||||
/// methods invoked on this object. If this value is null, [commands] is a list Dart statements that replicate the methods invoked on this object.
|
||||
PersistentStore? store;
|
||||
|
||||
/// Whether or not this builder should create temporary tables.
|
||||
bool isTemporary;
|
||||
|
||||
/// A list of commands generated by operations performed on this builder.
|
||||
///
|
||||
/// If [store] is non-null, these commands will be SQL commands that upgrade [inputSchema] to [schema] as determined by [store].
|
||||
/// If [store] is null, these commands are ;-terminated Dart expressions that replicate the methods to call on this object to upgrade [inputSchema] to [schema].
|
||||
List<String> commands = [];
|
||||
|
||||
/// Validates and adds a table to [schema].
|
||||
void createTable(SchemaTable table) {
|
||||
schema.addTable(table);
|
||||
|
||||
if (store != null) {
|
||||
commands.addAll(store!.createTable(table, isTemporary: isTemporary));
|
||||
} else {
|
||||
commands.add(_getNewTableExpression(table));
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and renames a table in [schema].
|
||||
void renameTable(String currentTableName, String newName) {
|
||||
final table = schema.tableForName(currentTableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $currentTableName does not exist.");
|
||||
}
|
||||
|
||||
schema.renameTable(table, newName);
|
||||
if (store != null) {
|
||||
commands.addAll(store!.renameTable(table, newName));
|
||||
} else {
|
||||
commands.add("database.renameTable('$currentTableName', '$newName');");
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and deletes a table in [schema].
|
||||
void deleteTable(String tableName) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
schema.removeTable(table);
|
||||
|
||||
if (store != null) {
|
||||
commands.addAll(store!.deleteTable(table));
|
||||
} else {
|
||||
commands.add('database.deleteTable("$tableName");');
|
||||
}
|
||||
}
|
||||
|
||||
/// Alters a table in [schema].
|
||||
void alterTable(
|
||||
String tableName,
|
||||
void Function(SchemaTable targetTable) modify,
|
||||
) {
|
||||
final existingTable = schema.tableForName(tableName);
|
||||
if (existingTable == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
final newTable = SchemaTable.from(existingTable);
|
||||
modify(newTable);
|
||||
schema.replaceTable(existingTable, newTable);
|
||||
|
||||
final shouldAddUnique = existingTable.uniqueColumnSet == null &&
|
||||
newTable.uniqueColumnSet != null;
|
||||
final shouldRemoveUnique = existingTable.uniqueColumnSet != null &&
|
||||
newTable.uniqueColumnSet == null;
|
||||
|
||||
final innerCommands = <String>[];
|
||||
if (shouldAddUnique) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.addTableUniqueColumnSet(newTable));
|
||||
} else {
|
||||
innerCommands.add(
|
||||
"t.uniqueColumnSet = [${newTable.uniqueColumnSet!.map((s) => '"$s"').join(',')}]",
|
||||
);
|
||||
}
|
||||
} else if (shouldRemoveUnique) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.deleteTableUniqueColumnSet(newTable));
|
||||
} else {
|
||||
innerCommands.add("t.uniqueColumnSet = null");
|
||||
}
|
||||
} else {
|
||||
final haveSameLength = existingTable.uniqueColumnSet!.length ==
|
||||
newTable.uniqueColumnSet!.length;
|
||||
final haveSameKeys = existingTable.uniqueColumnSet!
|
||||
.every((s) => newTable.uniqueColumnSet!.contains(s));
|
||||
|
||||
if (!haveSameKeys || !haveSameLength) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.deleteTableUniqueColumnSet(newTable));
|
||||
commands.addAll(store!.addTableUniqueColumnSet(newTable));
|
||||
} else {
|
||||
innerCommands.add(
|
||||
"t.uniqueColumnSet = [${newTable.uniqueColumnSet!.map((s) => '"$s"').join(',')}]",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (store == null && innerCommands.isNotEmpty) {
|
||||
commands.add(
|
||||
'database.alterTable("$tableName", (t) {${innerCommands.join(";")};});',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and adds a column to a table in [schema].
|
||||
void addColumn(
|
||||
String tableName,
|
||||
SchemaColumn column, {
|
||||
String? unencodedInitialValue,
|
||||
}) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
table.addColumn(column);
|
||||
if (store != null) {
|
||||
commands.addAll(
|
||||
store!.addColumn(
|
||||
table,
|
||||
column,
|
||||
unencodedInitialValue: unencodedInitialValue,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
commands.add(
|
||||
'database.addColumn("${column.table!.name}", ${_getNewColumnExpression(column)});',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and deletes a column in a table in [schema].
|
||||
void deleteColumn(String tableName, String columnName) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
final column = table.columnForName(columnName);
|
||||
if (column == null) {
|
||||
throw SchemaException("Column $columnName does not exists.");
|
||||
}
|
||||
|
||||
table.removeColumn(column);
|
||||
|
||||
if (store != null) {
|
||||
commands.addAll(store!.deleteColumn(table, column));
|
||||
} else {
|
||||
commands.add('database.deleteColumn("$tableName", "$columnName");');
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and renames a column in a table in [schema].
|
||||
void renameColumn(String tableName, String columnName, String newName) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
final column = table.columnForName(columnName);
|
||||
if (column == null) {
|
||||
throw SchemaException("Column $columnName does not exists.");
|
||||
}
|
||||
|
||||
table.renameColumn(column, newName);
|
||||
|
||||
if (store != null) {
|
||||
commands.addAll(store!.renameColumn(table, column, newName));
|
||||
} else {
|
||||
commands.add(
|
||||
"database.renameColumn('$tableName', '$columnName', '$newName');",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and alters a column in a table in [schema].
|
||||
///
|
||||
/// Alterations are made by setting properties of the column passed to [modify]. If the column's nullability
|
||||
/// changes from nullable to not nullable, all previously null values for that column
|
||||
/// are set to the value of [unencodedInitialValue].
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
/// database.alterColumn("table", "column", (c) {
|
||||
/// c.isIndexed = true;
|
||||
/// c.isNullable = false;
|
||||
/// }), unencodedInitialValue: "0");
|
||||
void alterColumn(
|
||||
String tableName,
|
||||
String columnName,
|
||||
void Function(SchemaColumn targetColumn) modify, {
|
||||
String? unencodedInitialValue,
|
||||
}) {
|
||||
final table = schema.tableForName(tableName);
|
||||
if (table == null) {
|
||||
throw SchemaException("Table $tableName does not exist.");
|
||||
}
|
||||
|
||||
final existingColumn = table[columnName];
|
||||
if (existingColumn == null) {
|
||||
throw SchemaException("Column $columnName does not exist.");
|
||||
}
|
||||
|
||||
final newColumn = SchemaColumn.from(existingColumn);
|
||||
modify(newColumn);
|
||||
|
||||
if (existingColumn.type != newColumn.type) {
|
||||
throw SchemaException(
|
||||
"May not change column type for '${existingColumn.name}' in '$tableName' (${existingColumn.typeString} -> ${newColumn.typeString})",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.autoincrement != newColumn.autoincrement) {
|
||||
throw SchemaException(
|
||||
"May not change column autoincrementing behavior for '${existingColumn.name}' in '$tableName'",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.isPrimaryKey != newColumn.isPrimaryKey) {
|
||||
throw SchemaException(
|
||||
"May not change column primary key status for '${existingColumn.name}' in '$tableName'",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.relatedTableName != newColumn.relatedTableName) {
|
||||
throw SchemaException(
|
||||
"May not change reference table for foreign key column '${existingColumn.name}' in '$tableName' (${existingColumn.relatedTableName} -> ${newColumn.relatedTableName})",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.relatedColumnName != newColumn.relatedColumnName) {
|
||||
throw SchemaException(
|
||||
"May not change reference column for foreign key column '${existingColumn.name}' in '$tableName' (${existingColumn.relatedColumnName} -> ${newColumn.relatedColumnName})",
|
||||
);
|
||||
}
|
||||
|
||||
if (existingColumn.name != newColumn.name) {
|
||||
renameColumn(tableName, existingColumn.name, newColumn.name);
|
||||
}
|
||||
|
||||
table.replaceColumn(existingColumn, newColumn);
|
||||
|
||||
final innerCommands = <String>[];
|
||||
if (existingColumn.isIndexed != newColumn.isIndexed) {
|
||||
if (store != null) {
|
||||
if (newColumn.isIndexed!) {
|
||||
commands.addAll(store!.addIndexToColumn(table, newColumn));
|
||||
} else {
|
||||
commands.addAll(store!.deleteIndexFromColumn(table, newColumn));
|
||||
}
|
||||
} else {
|
||||
innerCommands.add("c.isIndexed = ${newColumn.isIndexed}");
|
||||
}
|
||||
}
|
||||
|
||||
if (existingColumn.isUnique != newColumn.isUnique) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.alterColumnUniqueness(table, newColumn));
|
||||
} else {
|
||||
innerCommands.add('c.isUnique = ${newColumn.isUnique}');
|
||||
}
|
||||
}
|
||||
|
||||
if (existingColumn.defaultValue != newColumn.defaultValue) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.alterColumnDefaultValue(table, newColumn));
|
||||
} else {
|
||||
final value = newColumn.defaultValue == null
|
||||
? 'null'
|
||||
: '"${newColumn.defaultValue}"';
|
||||
innerCommands.add('c.defaultValue = $value');
|
||||
}
|
||||
}
|
||||
|
||||
if (existingColumn.isNullable != newColumn.isNullable) {
|
||||
if (store != null) {
|
||||
commands.addAll(
|
||||
store!
|
||||
.alterColumnNullability(table, newColumn, unencodedInitialValue),
|
||||
);
|
||||
} else {
|
||||
innerCommands.add('c.isNullable = ${newColumn.isNullable}');
|
||||
}
|
||||
}
|
||||
|
||||
if (existingColumn.deleteRule != newColumn.deleteRule) {
|
||||
if (store != null) {
|
||||
commands.addAll(store!.alterColumnDeleteRule(table, newColumn));
|
||||
} else {
|
||||
innerCommands.add('c.deleteRule = ${newColumn.deleteRule}');
|
||||
}
|
||||
}
|
||||
|
||||
if (store == null && innerCommands.isNotEmpty) {
|
||||
commands.add(
|
||||
'database.alterColumn("$tableName", "$columnName", (c) {${innerCommands.join(";")};});',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _generateSchemaCommands(
|
||||
SchemaDifference difference, {
|
||||
List<String>? changeList,
|
||||
bool temporary = false,
|
||||
}) {
|
||||
// We need to remove foreign keys from the initial table add and defer
|
||||
// them until after all tables in the schema have been created.
|
||||
// These can occur in both columns and multi column unique.
|
||||
// We'll split the creation of those tables into two different sets
|
||||
// of commands and run the difference afterwards
|
||||
final fkDifferences = <SchemaTableDifference>[];
|
||||
|
||||
for (final t in difference.tablesToAdd) {
|
||||
final copy = SchemaTable.from(t!);
|
||||
if (copy.hasForeignKeyInUniqueSet) {
|
||||
copy.uniqueColumnSet = null;
|
||||
}
|
||||
copy.columns.where((c) => c.isForeignKey).forEach(copy.removeColumn);
|
||||
|
||||
changeList?.add("Adding table '${copy.name}'");
|
||||
createTable(copy);
|
||||
|
||||
fkDifferences.add(SchemaTableDifference(copy, t));
|
||||
}
|
||||
|
||||
for (final td in fkDifferences) {
|
||||
_generateTableCommands(td, changeList: changeList);
|
||||
}
|
||||
|
||||
for (final t in difference.tablesToDelete) {
|
||||
changeList?.add("Deleting table '${t!.name}'");
|
||||
deleteTable(t!.name!);
|
||||
}
|
||||
|
||||
for (final t in difference.tablesToModify) {
|
||||
_generateTableCommands(t, changeList: changeList);
|
||||
}
|
||||
}
|
||||
|
||||
void _generateTableCommands(
|
||||
SchemaTableDifference difference, {
|
||||
List<String>? changeList,
|
||||
}) {
|
||||
for (final c in difference.columnsToAdd) {
|
||||
changeList?.add(
|
||||
"Adding column '${c!.name}' to table '${difference.actualTable!.name}'",
|
||||
);
|
||||
addColumn(difference.actualTable!.name!, c!);
|
||||
|
||||
if (!c.isNullable! && c.defaultValue == null) {
|
||||
changeList?.add(
|
||||
"WARNING: This migration may fail if table '${difference.actualTable!.name}' already has rows. "
|
||||
"Add an 'unencodedInitialValue' to the statement 'database.addColumn(\"${difference.actualTable!.name}\", "
|
||||
"SchemaColumn(\"${c.name}\", ...)'.");
|
||||
}
|
||||
}
|
||||
|
||||
for (final c in difference.columnsToRemove) {
|
||||
changeList?.add(
|
||||
"Deleting column '${c!.name}' from table '${difference.actualTable!.name}'",
|
||||
);
|
||||
deleteColumn(difference.actualTable!.name!, c!.name);
|
||||
}
|
||||
|
||||
for (final columnDiff in difference.columnsToModify) {
|
||||
changeList?.add(
|
||||
"Modifying column '${columnDiff.actualColumn!.name}' in '${difference.actualTable!.name}'",
|
||||
);
|
||||
alterColumn(difference.actualTable!.name!, columnDiff.actualColumn!.name,
|
||||
(c) {
|
||||
c.isIndexed = columnDiff.actualColumn!.isIndexed;
|
||||
c.defaultValue = columnDiff.actualColumn!.defaultValue;
|
||||
c.isUnique = columnDiff.actualColumn!.isUnique;
|
||||
c.isNullable = columnDiff.actualColumn!.isNullable;
|
||||
c.deleteRule = columnDiff.actualColumn!.deleteRule;
|
||||
});
|
||||
|
||||
if (columnDiff.expectedColumn!.isNullable! &&
|
||||
!columnDiff.actualColumn!.isNullable! &&
|
||||
columnDiff.actualColumn!.defaultValue == null) {
|
||||
changeList?.add(
|
||||
"WARNING: This migration may fail if table '${difference.actualTable!.name}' already has rows. "
|
||||
"Add an 'unencodedInitialValue' to the statement 'database.addColumn(\"${difference.actualTable!.name}\", "
|
||||
"SchemaColumn(\"${columnDiff.actualColumn!.name}\", ...)'.");
|
||||
}
|
||||
}
|
||||
|
||||
if (difference.uniqueSetDifference?.hasDifferences ?? false) {
|
||||
changeList?.add(
|
||||
"Setting unique column constraint of '${difference.actualTable!.name}' to ${difference.uniqueSetDifference!.actualColumnNames}.",
|
||||
);
|
||||
alterTable(difference.actualTable!.name!, (t) {
|
||||
if (difference.uniqueSetDifference!.actualColumnNames.isEmpty) {
|
||||
t.uniqueColumnSet = null;
|
||||
} else {
|
||||
t.uniqueColumnSet = difference.uniqueSetDifference!.actualColumnNames;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static String _getNewTableExpression(SchemaTable table) {
|
||||
final builder = StringBuffer();
|
||||
builder.write('database.createTable(SchemaTable("${table.name}", [');
|
||||
builder.write(table.columns.map(_getNewColumnExpression).join(","));
|
||||
builder.write("]");
|
||||
|
||||
if (table.uniqueColumnSet != null) {
|
||||
final set = table.uniqueColumnSet!.map((p) => '"$p"').join(",");
|
||||
builder.write(", uniqueColumnSetNames: [$set]");
|
||||
}
|
||||
|
||||
builder.write('));');
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
static String _getNewColumnExpression(SchemaColumn column) {
|
||||
final builder = StringBuffer();
|
||||
if (column.relatedTableName != null) {
|
||||
builder
|
||||
.write('SchemaColumn.relationship("${column.name}", ${column.type}');
|
||||
builder.write(', relatedTableName: "${column.relatedTableName}"');
|
||||
builder.write(', relatedColumnName: "${column.relatedColumnName}"');
|
||||
builder.write(", rule: ${column.deleteRule}");
|
||||
} else {
|
||||
builder.write('SchemaColumn("${column.name}", ${column.type}');
|
||||
if (column.isPrimaryKey!) {
|
||||
builder.write(", isPrimaryKey: true");
|
||||
} else {
|
||||
builder.write(", isPrimaryKey: false");
|
||||
}
|
||||
if (column.autoincrement!) {
|
||||
builder.write(", autoincrement: true");
|
||||
} else {
|
||||
builder.write(", autoincrement: false");
|
||||
}
|
||||
if (column.defaultValue != null) {
|
||||
builder.write(', defaultValue: "${column.defaultValue}"');
|
||||
}
|
||||
if (column.isIndexed!) {
|
||||
builder.write(", isIndexed: true");
|
||||
} else {
|
||||
builder.write(", isIndexed: false");
|
||||
}
|
||||
}
|
||||
|
||||
if (column.isNullable!) {
|
||||
builder.write(", isNullable: true");
|
||||
} else {
|
||||
builder.write(", isNullable: false");
|
||||
}
|
||||
if (column.isUnique!) {
|
||||
builder.write(", isUnique: true");
|
||||
} else {
|
||||
builder.write(", isUnique: false");
|
||||
}
|
||||
|
||||
builder.write(")");
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
423
packages/database/lib/src/schema/schema_column.dart
Normal file
423
packages/database/lib/src/schema/schema_column.dart
Normal file
|
@ -0,0 +1,423 @@
|
|||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
/// A portable representation of a database column.
|
||||
///
|
||||
/// Instances of this type contain the database-only details of a [ManagedPropertyDescription].
|
||||
class SchemaColumn {
|
||||
/// Creates an instance of this type from [name], [type] and other properties.
|
||||
SchemaColumn(
|
||||
this.name,
|
||||
ManagedPropertyType type, {
|
||||
this.isIndexed = false,
|
||||
this.isNullable = false,
|
||||
this.autoincrement = false,
|
||||
this.isUnique = false,
|
||||
this.defaultValue,
|
||||
this.isPrimaryKey = false,
|
||||
}) {
|
||||
_type = typeStringForType(type);
|
||||
}
|
||||
|
||||
/// A convenience constructor for properties that represent foreign key relationships.
|
||||
SchemaColumn.relationship(
|
||||
this.name,
|
||||
ManagedPropertyType type, {
|
||||
this.isNullable = true,
|
||||
this.isUnique = false,
|
||||
this.relatedTableName,
|
||||
this.relatedColumnName,
|
||||
DeleteRule rule = DeleteRule.nullify,
|
||||
}) {
|
||||
isIndexed = true;
|
||||
_type = typeStringForType(type);
|
||||
_deleteRule = deleteRuleStringForDeleteRule(rule);
|
||||
}
|
||||
|
||||
/// Creates an instance of this type to mirror [desc].
|
||||
SchemaColumn.fromProperty(ManagedPropertyDescription desc) {
|
||||
name = desc.name;
|
||||
|
||||
if (desc is ManagedRelationshipDescription) {
|
||||
isPrimaryKey = false;
|
||||
relatedTableName = desc.destinationEntity.tableName;
|
||||
relatedColumnName = desc.destinationEntity.primaryKey;
|
||||
if (desc.deleteRule != null) {
|
||||
_deleteRule = deleteRuleStringForDeleteRule(desc.deleteRule!);
|
||||
}
|
||||
} else if (desc is ManagedAttributeDescription) {
|
||||
defaultValue = desc.defaultValue;
|
||||
isPrimaryKey = desc.isPrimaryKey;
|
||||
}
|
||||
|
||||
_type = typeStringForType(desc.type!.kind);
|
||||
isNullable = desc.isNullable;
|
||||
autoincrement = desc.autoincrement;
|
||||
isUnique = desc.isUnique;
|
||||
isIndexed = desc.isIndexed;
|
||||
}
|
||||
|
||||
/// Creates a copy of [otherColumn].
|
||||
SchemaColumn.from(SchemaColumn otherColumn) {
|
||||
name = otherColumn.name;
|
||||
_type = otherColumn._type;
|
||||
isIndexed = otherColumn.isIndexed;
|
||||
isNullable = otherColumn.isNullable;
|
||||
autoincrement = otherColumn.autoincrement;
|
||||
isUnique = otherColumn.isUnique;
|
||||
defaultValue = otherColumn.defaultValue;
|
||||
isPrimaryKey = otherColumn.isPrimaryKey;
|
||||
relatedTableName = otherColumn.relatedTableName;
|
||||
relatedColumnName = otherColumn.relatedColumnName;
|
||||
_deleteRule = otherColumn._deleteRule;
|
||||
}
|
||||
|
||||
/// Creates an instance of this type from [map].
|
||||
///
|
||||
/// Where [map] is typically created by [asMap].
|
||||
SchemaColumn.fromMap(Map<String, dynamic> map) {
|
||||
name = map["name"] as String;
|
||||
_type = map["type"] as String?;
|
||||
isIndexed = map["indexed"] as bool?;
|
||||
isNullable = map["nullable"] as bool?;
|
||||
autoincrement = map["autoincrement"] as bool?;
|
||||
isUnique = map["unique"] as bool?;
|
||||
defaultValue = map["defaultValue"] as String?;
|
||||
isPrimaryKey = map["primaryKey"] as bool?;
|
||||
relatedTableName = map["relatedTableName"] as String?;
|
||||
relatedColumnName = map["relatedColumnName"] as String?;
|
||||
_deleteRule = map["deleteRule"] as String?;
|
||||
}
|
||||
|
||||
/// Creates an empty instance of this type.
|
||||
SchemaColumn.empty();
|
||||
|
||||
/// The name of this column.
|
||||
late String name;
|
||||
|
||||
/// The [SchemaTable] this column belongs to.
|
||||
///
|
||||
/// May be null if not assigned to a table.
|
||||
SchemaTable? table;
|
||||
|
||||
/// The [String] representation of this column's type.
|
||||
String? get typeString => _type;
|
||||
|
||||
/// The type of this column in a [ManagedDataModel].
|
||||
ManagedPropertyType? get type => typeFromTypeString(_type);
|
||||
|
||||
set type(ManagedPropertyType? t) {
|
||||
_type = typeStringForType(t);
|
||||
}
|
||||
|
||||
/// Whether or not this column is indexed.
|
||||
bool? isIndexed = false;
|
||||
|
||||
/// Whether or not this column is nullable.
|
||||
bool? isNullable = false;
|
||||
|
||||
/// Whether or not this column is autoincremented.
|
||||
bool? autoincrement = false;
|
||||
|
||||
/// Whether or not this column is unique.
|
||||
bool? isUnique = false;
|
||||
|
||||
/// The default value for this column when inserted into a database.
|
||||
String? defaultValue;
|
||||
|
||||
/// Whether or not this column is the primary key of its [table].
|
||||
bool? isPrimaryKey = false;
|
||||
|
||||
/// The related table name if this column is a foreign key column.
|
||||
///
|
||||
/// If this column has a foreign key constraint, this property is the name
|
||||
/// of the referenced table.
|
||||
///
|
||||
/// Null if this column is not a foreign key reference.
|
||||
String? relatedTableName;
|
||||
|
||||
/// The related column if this column is a foreign key column.
|
||||
///
|
||||
/// If this column has a foreign key constraint, this property is the name
|
||||
/// of the reference column in [relatedTableName].
|
||||
String? relatedColumnName;
|
||||
|
||||
/// The delete rule for this column if it is a foreign key column.
|
||||
///
|
||||
/// Undefined if not a foreign key column.
|
||||
DeleteRule? get deleteRule =>
|
||||
_deleteRule == null ? null : deleteRuleForDeleteRuleString(_deleteRule);
|
||||
|
||||
set deleteRule(DeleteRule? t) {
|
||||
if (t == null) {
|
||||
_deleteRule = null;
|
||||
} else {
|
||||
_deleteRule = deleteRuleStringForDeleteRule(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not this column is a foreign key column.
|
||||
bool get isForeignKey {
|
||||
return relatedTableName != null && relatedColumnName != null;
|
||||
}
|
||||
|
||||
String? _type;
|
||||
String? _deleteRule;
|
||||
|
||||
/// The differences between two columns.
|
||||
SchemaColumnDifference differenceFrom(SchemaColumn column) {
|
||||
return SchemaColumnDifference(this, column);
|
||||
}
|
||||
|
||||
/// Returns string representation of [ManagedPropertyType].
|
||||
static String? typeStringForType(ManagedPropertyType? type) {
|
||||
switch (type) {
|
||||
case ManagedPropertyType.integer:
|
||||
return "integer";
|
||||
case ManagedPropertyType.doublePrecision:
|
||||
return "double";
|
||||
case ManagedPropertyType.bigInteger:
|
||||
return "bigInteger";
|
||||
case ManagedPropertyType.boolean:
|
||||
return "boolean";
|
||||
case ManagedPropertyType.datetime:
|
||||
return "datetime";
|
||||
case ManagedPropertyType.string:
|
||||
return "string";
|
||||
case ManagedPropertyType.list:
|
||||
return null;
|
||||
case ManagedPropertyType.map:
|
||||
return null;
|
||||
case ManagedPropertyType.document:
|
||||
return "document";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns inverse of [typeStringForType].
|
||||
static ManagedPropertyType? typeFromTypeString(String? type) {
|
||||
switch (type) {
|
||||
case "integer":
|
||||
return ManagedPropertyType.integer;
|
||||
case "double":
|
||||
return ManagedPropertyType.doublePrecision;
|
||||
case "bigInteger":
|
||||
return ManagedPropertyType.bigInteger;
|
||||
case "boolean":
|
||||
return ManagedPropertyType.boolean;
|
||||
case "datetime":
|
||||
return ManagedPropertyType.datetime;
|
||||
case "string":
|
||||
return ManagedPropertyType.string;
|
||||
case "document":
|
||||
return ManagedPropertyType.document;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns string representation of [DeleteRule].
|
||||
static String? deleteRuleStringForDeleteRule(DeleteRule rule) {
|
||||
switch (rule) {
|
||||
case DeleteRule.cascade:
|
||||
return "cascade";
|
||||
case DeleteRule.nullify:
|
||||
return "nullify";
|
||||
case DeleteRule.restrict:
|
||||
return "restrict";
|
||||
case DeleteRule.setDefault:
|
||||
return "default";
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns inverse of [deleteRuleStringForDeleteRule].
|
||||
static DeleteRule? deleteRuleForDeleteRuleString(String? rule) {
|
||||
switch (rule) {
|
||||
case "cascade":
|
||||
return DeleteRule.cascade;
|
||||
case "nullify":
|
||||
return DeleteRule.nullify;
|
||||
case "restrict":
|
||||
return DeleteRule.restrict;
|
||||
case "default":
|
||||
return DeleteRule.setDefault;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns portable representation of this instance.
|
||||
Map<String, dynamic> asMap() {
|
||||
return {
|
||||
"name": name,
|
||||
"type": _type,
|
||||
"nullable": isNullable,
|
||||
"autoincrement": autoincrement,
|
||||
"unique": isUnique,
|
||||
"defaultValue": defaultValue,
|
||||
"primaryKey": isPrimaryKey,
|
||||
"relatedTableName": relatedTableName,
|
||||
"relatedColumnName": relatedColumnName,
|
||||
"deleteRule": _deleteRule,
|
||||
"indexed": isIndexed
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => "$name (-> $relatedTableName.$relatedColumnName)";
|
||||
}
|
||||
|
||||
/// The difference between two compared [SchemaColumn]s.
|
||||
///
|
||||
/// This class is used for comparing database columns for validation and migration.
|
||||
class SchemaColumnDifference {
|
||||
/// Creates a new instance that represents the difference between [expectedColumn] and [actualColumn].
|
||||
SchemaColumnDifference(this.expectedColumn, this.actualColumn) {
|
||||
if (actualColumn != null && expectedColumn != null) {
|
||||
if (actualColumn!.isPrimaryKey != expectedColumn!.isPrimaryKey) {
|
||||
throw SchemaException(
|
||||
"Cannot change primary key of '${expectedColumn!.table!.name}'",
|
||||
);
|
||||
}
|
||||
|
||||
if (actualColumn!.relatedColumnName !=
|
||||
expectedColumn!.relatedColumnName) {
|
||||
throw SchemaException(
|
||||
"Cannot change an existing column '${expectedColumn!.table!.name}.${expectedColumn!.name}' to an inverse Relationship",
|
||||
);
|
||||
}
|
||||
|
||||
if (actualColumn!.relatedTableName != expectedColumn!.relatedTableName) {
|
||||
throw SchemaException(
|
||||
"Cannot change type of '${expectedColumn!.table!.name}.${expectedColumn!.name}'",
|
||||
);
|
||||
}
|
||||
|
||||
if (actualColumn!.type != expectedColumn!.type) {
|
||||
throw SchemaException(
|
||||
"Cannot change type of '${expectedColumn!.table!.name}.${expectedColumn!.name}'",
|
||||
);
|
||||
}
|
||||
|
||||
if (actualColumn!.autoincrement != expectedColumn!.autoincrement) {
|
||||
throw SchemaException(
|
||||
"Cannot change autoincrement behavior of '${expectedColumn!.table!.name}.${expectedColumn!.name}'",
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.name.toLowerCase() !=
|
||||
actualColumn!.name.toLowerCase()) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"name",
|
||||
expectedColumn!.name,
|
||||
actualColumn!.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.isIndexed != actualColumn!.isIndexed) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"isIndexed",
|
||||
expectedColumn!.isIndexed,
|
||||
actualColumn!.isIndexed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.isUnique != actualColumn!.isUnique) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"isUnique",
|
||||
expectedColumn!.isUnique,
|
||||
actualColumn!.isUnique,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.isNullable != actualColumn!.isNullable) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"isNullable",
|
||||
expectedColumn!.isNullable,
|
||||
actualColumn!.isNullable,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.defaultValue != actualColumn!.defaultValue) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"defaultValue",
|
||||
expectedColumn!.defaultValue,
|
||||
actualColumn!.defaultValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedColumn!.deleteRule != actualColumn!.deleteRule) {
|
||||
_differingProperties.add(
|
||||
_PropertyDifference(
|
||||
"deleteRule",
|
||||
expectedColumn!.deleteRule,
|
||||
actualColumn!.deleteRule,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The expected column.
|
||||
///
|
||||
/// May be null if there is no column expected.
|
||||
final SchemaColumn? expectedColumn;
|
||||
|
||||
/// The actual column.
|
||||
///
|
||||
/// May be null if there is no actual column.
|
||||
final SchemaColumn? actualColumn;
|
||||
|
||||
/// Whether or not [expectedColumn] and [actualColumn] are different.
|
||||
bool get hasDifferences =>
|
||||
_differingProperties.isNotEmpty ||
|
||||
(expectedColumn == null && actualColumn != null) ||
|
||||
(actualColumn == null && expectedColumn != null);
|
||||
|
||||
/// Human-readable list of differences between [expectedColumn] and [actualColumn].
|
||||
///
|
||||
/// Empty is there are no differences.
|
||||
List<String> get errorMessages {
|
||||
if (expectedColumn == null && actualColumn != null) {
|
||||
return [
|
||||
"Column '${actualColumn!.name}' in table '${actualColumn!.table!.name}' should NOT exist, but is created by migration files"
|
||||
];
|
||||
} else if (expectedColumn != null && actualColumn == null) {
|
||||
return [
|
||||
"Column '${expectedColumn!.name}' in table '${expectedColumn!.table!.name}' should exist, but is NOT created by migration files"
|
||||
];
|
||||
}
|
||||
|
||||
return _differingProperties.map((property) {
|
||||
return property.getErrorMessage(
|
||||
expectedColumn!.table!.name,
|
||||
expectedColumn!.name,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final List<_PropertyDifference> _differingProperties = [];
|
||||
}
|
||||
|
||||
class _PropertyDifference {
|
||||
_PropertyDifference(this.name, this.expectedValue, this.actualValue);
|
||||
|
||||
final String name;
|
||||
final dynamic expectedValue;
|
||||
final dynamic actualValue;
|
||||
|
||||
String getErrorMessage(String? actualTableName, String? expectedColumnName) {
|
||||
return "Column '$expectedColumnName' in table '$actualTableName' expected "
|
||||
"'$expectedValue' for '$name', but migration files yield '$actualValue'";
|
||||
}
|
||||
}
|
351
packages/database/lib/src/schema/schema_table.dart
Normal file
351
packages/database/lib/src/schema/schema_table.dart
Normal file
|
@ -0,0 +1,351 @@
|
|||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:protevus_database/src/managed/managed.dart';
|
||||
import 'package:protevus_database/src/managed/relationship_type.dart';
|
||||
import 'package:protevus_database/src/schema/schema.dart';
|
||||
|
||||
/// A portable representation of a database table.
|
||||
///
|
||||
/// Instances of this type contain the database-only details of a [ManagedEntity]. See also [Schema].
|
||||
class SchemaTable {
|
||||
/// Creates an instance of this type with a [name], [columns] and [uniqueColumnSetNames].
|
||||
SchemaTable(
|
||||
this.name,
|
||||
List<SchemaColumn> columns, {
|
||||
List<String>? uniqueColumnSetNames,
|
||||
}) {
|
||||
uniqueColumnSet = uniqueColumnSetNames;
|
||||
_columns = columns;
|
||||
}
|
||||
|
||||
/// Creates an instance of this type to mirror [entity].
|
||||
SchemaTable.fromEntity(ManagedEntity entity) {
|
||||
name = entity.tableName;
|
||||
|
||||
final validProperties = entity.properties.values
|
||||
.where(
|
||||
(p) =>
|
||||
(p is ManagedAttributeDescription && !p.isTransient) ||
|
||||
(p is ManagedRelationshipDescription &&
|
||||
p.relationshipType == ManagedRelationshipType.belongsTo),
|
||||
)
|
||||
.toList();
|
||||
|
||||
_columns =
|
||||
validProperties.map((p) => SchemaColumn.fromProperty(p!)).toList();
|
||||
|
||||
uniqueColumnSet = entity.uniquePropertySet?.map((p) => p.name).toList();
|
||||
}
|
||||
|
||||
/// Creates a deep copy of [otherTable].
|
||||
SchemaTable.from(SchemaTable otherTable) {
|
||||
name = otherTable.name;
|
||||
_columns = otherTable.columns.map((col) => SchemaColumn.from(col)).toList();
|
||||
_uniqueColumnSet = otherTable._uniqueColumnSet;
|
||||
}
|
||||
|
||||
/// Creates an empty table.
|
||||
SchemaTable.empty();
|
||||
|
||||
/// Creates an instance of this type from [map].
|
||||
///
|
||||
/// This [map] is typically generated from [asMap];
|
||||
SchemaTable.fromMap(Map<String, dynamic> map) {
|
||||
name = map["name"] as String?;
|
||||
_columns = (map["columns"] as List<Map<String, dynamic>>)
|
||||
.map((c) => SchemaColumn.fromMap(c))
|
||||
.toList();
|
||||
uniqueColumnSet = (map["unique"] as List?)?.cast();
|
||||
}
|
||||
|
||||
/// The [Schema] this table belongs to.
|
||||
///
|
||||
/// May be null if not assigned to a [Schema].
|
||||
Schema? schema;
|
||||
|
||||
/// The name of the database table.
|
||||
String? name;
|
||||
|
||||
/// The names of a set of columns that must be unique for each row in this table.
|
||||
///
|
||||
/// Are sorted alphabetically. Not modifiable.
|
||||
List<String>? get uniqueColumnSet =>
|
||||
_uniqueColumnSet != null ? List.unmodifiable(_uniqueColumnSet!) : null;
|
||||
|
||||
set uniqueColumnSet(List<String>? columnNames) {
|
||||
if (columnNames != null) {
|
||||
_uniqueColumnSet = List.from(columnNames);
|
||||
_uniqueColumnSet?.sort((String a, String b) => a.compareTo(b));
|
||||
} else {
|
||||
_uniqueColumnSet = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// An unmodifiable list of [SchemaColumn]s in this table.
|
||||
List<SchemaColumn> get columns => List.unmodifiable(_columnStorage ?? []);
|
||||
|
||||
bool get hasForeignKeyInUniqueSet => columns
|
||||
.where((c) => c.isForeignKey)
|
||||
.any((c) => uniqueColumnSet?.contains(c.name) ?? false);
|
||||
|
||||
List<SchemaColumn>? _columnStorage;
|
||||
List<String>? _uniqueColumnSet;
|
||||
|
||||
set _columns(List<SchemaColumn> columns) {
|
||||
_columnStorage = columns;
|
||||
for (final c in _columnStorage!) {
|
||||
c.table = this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a [SchemaColumn] in this instance by its name.
|
||||
///
|
||||
/// See [columnForName] for more details.
|
||||
SchemaColumn? operator [](String columnName) => columnForName(columnName);
|
||||
|
||||
/// The differences between two tables.
|
||||
SchemaTableDifference differenceFrom(SchemaTable table) {
|
||||
return SchemaTableDifference(this, table);
|
||||
}
|
||||
|
||||
/// Adds [column] to this table.
|
||||
///
|
||||
/// Sets [column]'s [SchemaColumn.table] to this instance.
|
||||
void addColumn(SchemaColumn column) {
|
||||
if (this[column.name] != null) {
|
||||
throw SchemaException("Column ${column.name} already exists.");
|
||||
}
|
||||
|
||||
_columnStorage!.add(column);
|
||||
column.table = this;
|
||||
}
|
||||
|
||||
void renameColumn(SchemaColumn column, String? newName) {
|
||||
throw SchemaException("Renaming a column not yet implemented!");
|
||||
|
||||
// if (!columns.contains(column)) {
|
||||
// throw new SchemaException("Column ${column.name} does not exist on ${name}.");
|
||||
// }
|
||||
//
|
||||
// if (columnForName(newName) != null) {
|
||||
// throw new SchemaException("Column ${newName} already exists.");
|
||||
// }
|
||||
//
|
||||
// if (column.isPrimaryKey) {
|
||||
// throw new SchemaException("May not rename primary key column (${column.name} -> ${newName})");
|
||||
// }
|
||||
//
|
||||
// // We also must rename indices
|
||||
// column.name = newName;
|
||||
}
|
||||
|
||||
/// Removes [column] from this table.
|
||||
///
|
||||
/// Exact [column] must be in this table, else an exception is thrown.
|
||||
/// Sets [column]'s [SchemaColumn.table] to null.
|
||||
void removeColumn(SchemaColumn column) {
|
||||
if (!columns.contains(column)) {
|
||||
throw SchemaException("Column ${column.name} does not exist on $name.");
|
||||
}
|
||||
|
||||
_columnStorage!.remove(column);
|
||||
column.table = null;
|
||||
}
|
||||
|
||||
/// Replaces [existingColumn] with [newColumn] in this table.
|
||||
void replaceColumn(SchemaColumn existingColumn, SchemaColumn newColumn) {
|
||||
if (!columns.contains(existingColumn)) {
|
||||
throw SchemaException(
|
||||
"Column ${existingColumn.name} does not exist on $name.",
|
||||
);
|
||||
}
|
||||
|
||||
final index = _columnStorage!.indexOf(existingColumn);
|
||||
_columnStorage![index] = newColumn;
|
||||
newColumn.table = this;
|
||||
existingColumn.table = null;
|
||||
}
|
||||
|
||||
/// Returns a [SchemaColumn] with [name].
|
||||
///
|
||||
/// Case-insensitively compares names of [columns] with [name]. Returns null if no column exists
|
||||
/// with [name].
|
||||
SchemaColumn? columnForName(String name) {
|
||||
final lowercaseName = name.toLowerCase();
|
||||
return columns
|
||||
.firstWhereOrNull((col) => col.name.toLowerCase() == lowercaseName);
|
||||
}
|
||||
|
||||
/// Returns portable representation of this table.
|
||||
Map<String, dynamic> asMap() {
|
||||
return {
|
||||
"name": name,
|
||||
"columns": columns.map((c) => c.asMap()).toList(),
|
||||
"unique": uniqueColumnSet
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => name!;
|
||||
}
|
||||
|
||||
/// The difference between two [SchemaTable]s.
|
||||
///
|
||||
/// This class is used for comparing schemas for validation and migration.
|
||||
class SchemaTableDifference {
|
||||
/// Creates a new instance that represents the difference between [expectedTable] and [actualTable].
|
||||
SchemaTableDifference(this.expectedTable, this.actualTable) {
|
||||
if (expectedTable != null && actualTable != null) {
|
||||
for (final expectedColumn in expectedTable!.columns) {
|
||||
final actualColumn =
|
||||
actualTable != null ? actualTable![expectedColumn.name] : null;
|
||||
if (actualColumn == null) {
|
||||
_differingColumns.add(SchemaColumnDifference(expectedColumn, null));
|
||||
} else {
|
||||
final diff = expectedColumn.differenceFrom(actualColumn);
|
||||
if (diff.hasDifferences) {
|
||||
_differingColumns.add(diff);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_differingColumns.addAll(
|
||||
actualTable!.columns
|
||||
.where((t) => expectedTable![t.name] == null)
|
||||
.map((unexpectedColumn) {
|
||||
return SchemaColumnDifference(null, unexpectedColumn);
|
||||
}),
|
||||
);
|
||||
|
||||
uniqueSetDifference =
|
||||
SchemaTableUniqueSetDifference(expectedTable!, actualTable!);
|
||||
}
|
||||
}
|
||||
|
||||
/// The expected table.
|
||||
///
|
||||
/// May be null if no table is expected.
|
||||
final SchemaTable? expectedTable;
|
||||
|
||||
/// The actual table.
|
||||
///
|
||||
/// May be null if there is no table.
|
||||
final SchemaTable? actualTable;
|
||||
|
||||
/// The difference between [SchemaTable.uniqueColumnSet]s.
|
||||
///
|
||||
/// Null if either [expectedTable] or [actualTable] are null.
|
||||
SchemaTableUniqueSetDifference? uniqueSetDifference;
|
||||
|
||||
/// Whether or not [expectedTable] and [actualTable] are the same.
|
||||
bool get hasDifferences =>
|
||||
_differingColumns.isNotEmpty ||
|
||||
expectedTable?.name?.toLowerCase() != actualTable?.name?.toLowerCase() ||
|
||||
(expectedTable == null && actualTable != null) ||
|
||||
(actualTable == null && expectedTable != null) ||
|
||||
(uniqueSetDifference?.hasDifferences ?? false);
|
||||
|
||||
/// Human-readable list of differences between [expectedTable] and [actualTable].
|
||||
List<String> get errorMessages {
|
||||
if (expectedTable == null && actualTable != null) {
|
||||
return [
|
||||
"Table '$actualTable' should NOT exist, but is created by migration files."
|
||||
];
|
||||
} else if (expectedTable != null && actualTable == null) {
|
||||
return [
|
||||
"Table '$expectedTable' should exist, but it is NOT created by migration files."
|
||||
];
|
||||
}
|
||||
|
||||
final diffs =
|
||||
_differingColumns.expand((diff) => diff.errorMessages).toList();
|
||||
diffs.addAll(uniqueSetDifference?.errorMessages ?? []);
|
||||
|
||||
return diffs;
|
||||
}
|
||||
|
||||
List<SchemaColumnDifference> get columnDifferences => _differingColumns;
|
||||
|
||||
List<SchemaColumn?> get columnsToAdd {
|
||||
return _differingColumns
|
||||
.where(
|
||||
(diff) => diff.expectedColumn == null && diff.actualColumn != null,
|
||||
)
|
||||
.map((diff) => diff.actualColumn)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<SchemaColumn?> get columnsToRemove {
|
||||
return _differingColumns
|
||||
.where(
|
||||
(diff) => diff.expectedColumn != null && diff.actualColumn == null,
|
||||
)
|
||||
.map((diff) => diff.expectedColumn)
|
||||
.toList();
|
||||
}
|
||||
|
||||
List<SchemaColumnDifference> get columnsToModify {
|
||||
return _differingColumns
|
||||
.where(
|
||||
(columnDiff) =>
|
||||
columnDiff.expectedColumn != null &&
|
||||
columnDiff.actualColumn != null,
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
final List<SchemaColumnDifference> _differingColumns = [];
|
||||
}
|
||||
|
||||
/// Difference between two [SchemaTable.uniqueColumnSet]s.
|
||||
class SchemaTableUniqueSetDifference {
|
||||
SchemaTableUniqueSetDifference(
|
||||
SchemaTable expectedTable,
|
||||
SchemaTable actualTable,
|
||||
) : expectedColumnNames = expectedTable.uniqueColumnSet ?? [],
|
||||
actualColumnNames = actualTable.uniqueColumnSet ?? [],
|
||||
_tableName = actualTable.name;
|
||||
|
||||
/// The expected set of unique column names.
|
||||
final List<String> expectedColumnNames;
|
||||
|
||||
/// The actual set of unique column names.
|
||||
final List<String> actualColumnNames;
|
||||
|
||||
final String? _tableName;
|
||||
|
||||
/// Whether or not [expectedColumnNames] and [actualColumnNames] are equivalent.
|
||||
bool get hasDifferences {
|
||||
if (expectedColumnNames.length != actualColumnNames.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return !expectedColumnNames.every(actualColumnNames.contains);
|
||||
}
|
||||
|
||||
/// Human-readable list of differences between [expectedColumnNames] and [actualColumnNames].
|
||||
List<String> get errorMessages {
|
||||
if (expectedColumnNames.isEmpty && actualColumnNames.isNotEmpty) {
|
||||
return [
|
||||
"Multi-column unique constraint on table '$_tableName' "
|
||||
"should NOT exist, but is created by migration files."
|
||||
];
|
||||
} else if (expectedColumnNames.isNotEmpty && actualColumnNames.isEmpty) {
|
||||
return [
|
||||
"Multi-column unique constraint on table '$_tableName' "
|
||||
"should exist, but it is NOT created by migration files."
|
||||
];
|
||||
}
|
||||
|
||||
if (hasDifferences) {
|
||||
final expectedColumns = expectedColumnNames.map((c) => "'$c'").join(", ");
|
||||
final actualColumns = actualColumnNames.map((c) => "'$c'").join(", ");
|
||||
|
||||
return [
|
||||
"Multi-column unique constraint on table '$_tableName' "
|
||||
"is expected to be for properties $expectedColumns, but is actually $actualColumns"
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
|
@ -10,6 +10,11 @@ environment:
|
|||
|
||||
# Add regular dependencies here.
|
||||
dependencies:
|
||||
protevus_http: ^0.0.1
|
||||
protevus_openapi: ^0.0.1
|
||||
protevus_runtime: ^0.0.1
|
||||
collection: ^1.18.0
|
||||
meta: ^1.12.0
|
||||
# path: ^1.8.0
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
@ -1 +1,39 @@
|
|||
<p align="center"><a href="https://protevus.com" target="_blank"><img src="https://git.protevus.com/protevus/branding/raw/branch/main/protevus-logo-bg.png"></a></p>
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/guides/libraries/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-library-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/developing-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
|
|
20
packages/hashing/lib/hashing.dart
Normal file
20
packages/hashing/lib/hashing.dart
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/// This library provides hashing functionality for the Protevus Platform.
|
||||
///
|
||||
/// It exports two main components:
|
||||
/// - PBKDF2 (Password-Based Key Derivation Function 2) implementation
|
||||
/// - Salt generation utilities
|
||||
///
|
||||
/// These components are essential for secure password hashing and storage.
|
||||
library hashing;
|
||||
|
||||
export 'package:protevus_hashing/src/pbkdf2.dart';
|
||||
export 'package:protevus_hashing/src/salt.dart';
|
140
packages/hashing/lib/src/pbkdf2.dart
Normal file
140
packages/hashing/lib/src/pbkdf2.dart
Normal file
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
/// Instances of this type derive a key from a password, salt, and hash function.
|
||||
///
|
||||
/// https://en.wikipedia.org/wiki/PBKDF2
|
||||
class PBKDF2 {
|
||||
/// Creates instance capable of generating a key.
|
||||
///
|
||||
/// [hashAlgorithm] defaults to [sha256].
|
||||
PBKDF2({Hash? hashAlgorithm}) {
|
||||
this.hashAlgorithm = hashAlgorithm ?? sha256;
|
||||
}
|
||||
|
||||
Hash get hashAlgorithm => _hashAlgorithm;
|
||||
set hashAlgorithm(Hash algorithm) {
|
||||
_hashAlgorithm = algorithm;
|
||||
_blockSize = _hashAlgorithm.convert([1, 2, 3]).bytes.length;
|
||||
}
|
||||
|
||||
late Hash _hashAlgorithm;
|
||||
late int _blockSize;
|
||||
|
||||
/// Hashes a [password] with a given [salt].
|
||||
///
|
||||
/// The length of this return value will be [keyLength].
|
||||
///
|
||||
/// See [generateAsBase64String] for generating a random salt.
|
||||
///
|
||||
/// See also [generateBase64Key], which base64 encodes the key returned from this method for storage.
|
||||
List<int> generateKey(
|
||||
String password,
|
||||
String salt,
|
||||
int rounds,
|
||||
int keyLength,
|
||||
) {
|
||||
if (keyLength > (pow(2, 32) - 1) * _blockSize) {
|
||||
throw PBKDF2Exception("Derived key too long");
|
||||
}
|
||||
|
||||
final numberOfBlocks = (keyLength / _blockSize).ceil();
|
||||
final hmac = Hmac(hashAlgorithm, utf8.encode(password));
|
||||
final key = ByteData(keyLength);
|
||||
var offset = 0;
|
||||
|
||||
final saltBytes = utf8.encode(salt);
|
||||
final saltLength = saltBytes.length;
|
||||
final inputBuffer = ByteData(saltBytes.length + 4)
|
||||
..buffer.asUint8List().setRange(0, saltBytes.length, saltBytes);
|
||||
|
||||
for (var blockNumber = 1; blockNumber <= numberOfBlocks; blockNumber++) {
|
||||
inputBuffer.setUint8(saltLength, blockNumber >> 24);
|
||||
inputBuffer.setUint8(saltLength + 1, blockNumber >> 16);
|
||||
inputBuffer.setUint8(saltLength + 2, blockNumber >> 8);
|
||||
inputBuffer.setUint8(saltLength + 3, blockNumber);
|
||||
|
||||
final block = _XORDigestSink.generate(inputBuffer, hmac, rounds);
|
||||
var blockLength = _blockSize;
|
||||
if (offset + blockLength > keyLength) {
|
||||
blockLength = keyLength - offset;
|
||||
}
|
||||
key.buffer.asUint8List().setRange(offset, offset + blockLength, block);
|
||||
|
||||
offset += blockLength;
|
||||
}
|
||||
|
||||
return key.buffer.asUint8List();
|
||||
}
|
||||
|
||||
/// Hashed a [password] with a given [salt] and base64 encodes the result.
|
||||
///
|
||||
/// This method invokes [generateKey] and base64 encodes the result.
|
||||
String generateBase64Key(
|
||||
String password,
|
||||
String salt,
|
||||
int rounds,
|
||||
int keyLength,
|
||||
) {
|
||||
const converter = Base64Encoder();
|
||||
|
||||
return converter.convert(generateKey(password, salt, rounds, keyLength));
|
||||
}
|
||||
}
|
||||
|
||||
/// Thrown when [PBKDF2] throws an exception.
|
||||
class PBKDF2Exception implements Exception {
|
||||
PBKDF2Exception(this.message);
|
||||
String message;
|
||||
|
||||
@override
|
||||
String toString() => "PBKDF2Exception: $message";
|
||||
}
|
||||
|
||||
class _XORDigestSink implements Sink<Digest> {
|
||||
_XORDigestSink(ByteData inputBuffer, Hmac hmac) {
|
||||
lastDigest = hmac.convert(inputBuffer.buffer.asUint8List()).bytes;
|
||||
bytes = ByteData(lastDigest.length)
|
||||
..buffer.asUint8List().setRange(0, lastDigest.length, lastDigest);
|
||||
}
|
||||
|
||||
static Uint8List generate(ByteData inputBuffer, Hmac hmac, int rounds) {
|
||||
final hashSink = _XORDigestSink(inputBuffer, hmac);
|
||||
|
||||
// If rounds == 1, we have already run the first hash in the constructor
|
||||
// so this loop won't run.
|
||||
for (var round = 1; round < rounds; round++) {
|
||||
final hmacSink = hmac.startChunkedConversion(hashSink);
|
||||
hmacSink.add(hashSink.lastDigest);
|
||||
hmacSink.close();
|
||||
}
|
||||
|
||||
return hashSink.bytes.buffer.asUint8List();
|
||||
}
|
||||
|
||||
late ByteData bytes;
|
||||
late List<int> lastDigest;
|
||||
|
||||
@override
|
||||
void add(Digest digest) {
|
||||
lastDigest = digest.bytes;
|
||||
for (var i = 0; i < digest.bytes.length; i++) {
|
||||
bytes.setUint8(i, bytes.getUint8(i) ^ lastDigest[i]);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {}
|
||||
}
|
34
packages/hashing/lib/src/salt.dart
Normal file
34
packages/hashing/lib/src/salt.dart
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* This file is part of the Protevus Platform.
|
||||
*
|
||||
* (C) Protevus <developers@protevus.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// Generates a random salt of [length] bytes from a cryptographically secure random number generator.
|
||||
///
|
||||
/// Each element of this list is a byte.
|
||||
List<int> generate(int length) {
|
||||
final buffer = Uint8List(length);
|
||||
final rng = Random.secure();
|
||||
for (var i = 0; i < length; i++) {
|
||||
buffer[i] = rng.nextInt(256);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/// Generates a random salt of [length] bytes from a cryptographically secure random number generator and encodes it to Base64.
|
||||
///
|
||||
/// [length] is the number of bytes generated, not the [length] of the base64 encoded string returned. Decoding
|
||||
/// the base64 encoded string will yield [length] number of bytes.
|
||||
String generateAsBase64String(int length) {
|
||||
const encoder = Base64Encoder();
|
||||
return encoder.convert(generate(length));
|
||||
}
|
21
packages/http/lib/http.dart
Normal file
21
packages/http/lib/http.dart
Normal file
|
@ -0,0 +1,21 @@
|
|||
export 'src/body_decoder.dart';
|
||||
export 'src/cache_policy.dart';
|
||||
export 'src/controller.dart';
|
||||
export 'src/cors_policy.dart';
|
||||
export 'src/file_controller.dart';
|
||||
export 'src/handler_exception.dart';
|
||||
export 'src/http_codec_repository.dart';
|
||||
export 'src/managed_object_controller.dart';
|
||||
export 'src/query_controller.dart';
|
||||
export 'src/request.dart';
|
||||
export 'src/request_body.dart';
|
||||
export 'src/request_path.dart';
|
||||
export 'src/resource_controller.dart';
|
||||
export 'src/resource_controller_bindings.dart';
|
||||
export 'src/resource_controller_interfaces.dart';
|
||||
export 'src/resource_controller_scope.dart';
|
||||
export 'src/response.dart';
|
||||
export 'src/route_node.dart';
|
||||
export 'src/route_specification.dart';
|
||||
export 'src/router.dart';
|
||||
export 'src/serializable.dart';
|
143
packages/http/lib/src/body_decoder.dart
Normal file
143
packages/http/lib/src/body_decoder.dart
Normal file
|
@ -0,0 +1,143 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Decodes [bytes] according to [contentType].
|
||||
///
|
||||
/// See [RequestBody] for a concrete implementation.
|
||||
abstract class BodyDecoder {
|
||||
BodyDecoder(Stream<List<int>> bodyByteStream)
|
||||
: _originalByteStream = bodyByteStream;
|
||||
|
||||
/// The stream of bytes to decode.
|
||||
///
|
||||
/// This stream is consumed during decoding.
|
||||
Stream<List<int>> get bytes => _originalByteStream;
|
||||
|
||||
/// Determines how [bytes] get decoded.
|
||||
///
|
||||
/// A decoder is chosen from [CodecRegistry] according to this value.
|
||||
ContentType? get contentType;
|
||||
|
||||
/// Whether or not [bytes] is empty.
|
||||
///
|
||||
/// No decoding will occur if this flag is true.
|
||||
///
|
||||
/// Concrete implementations provide an implementation for this method without inspecting
|
||||
/// [bytes].
|
||||
bool get isEmpty;
|
||||
|
||||
/// Whether or not [bytes] are available as a list after decoding has occurred.
|
||||
///
|
||||
/// By default, invoking [decode] will discard
|
||||
/// the initial bytes and only keep the decoded value. Setting this flag to true
|
||||
/// will keep a copy of the original bytes in [originalBytes].
|
||||
bool retainOriginalBytes = false;
|
||||
|
||||
/// Whether or not [bytes] have been decoded yet.
|
||||
///
|
||||
/// If [isEmpty] is true, this value is always true.
|
||||
bool get hasBeenDecoded => _decodedData != null || isEmpty;
|
||||
|
||||
/// The type of data [bytes] was decoded into.
|
||||
///
|
||||
/// Will throw an error if [bytes] have not been decoded yet.
|
||||
Type get decodedType {
|
||||
if (!hasBeenDecoded) {
|
||||
throw StateError(
|
||||
"Invalid body decoding. Must decode data prior to calling 'decodedType'.",
|
||||
);
|
||||
}
|
||||
|
||||
return _decodedData.runtimeType;
|
||||
}
|
||||
|
||||
/// The raw bytes of this request body.
|
||||
///
|
||||
/// This value is valid if [retainOriginalBytes] was set to true prior to [decode] being invoked.
|
||||
List<int>? get originalBytes {
|
||||
if (retainOriginalBytes == false) {
|
||||
throw StateError(
|
||||
"'originalBytes' were not retained. Set 'retainOriginalBytes' to true prior to decoding.",
|
||||
);
|
||||
}
|
||||
return _bytes;
|
||||
}
|
||||
|
||||
final Stream<List<int>> _originalByteStream;
|
||||
dynamic _decodedData;
|
||||
List<int>? _bytes;
|
||||
|
||||
/// Decodes this object's bytes as [T].
|
||||
///
|
||||
/// This method will select the [Codec] for [contentType] from the [CodecRegistry].
|
||||
/// The bytes of this object will be decoded according to that codec. If the codec
|
||||
/// produces a value that is not [T], a bad request error [Response] is thrown.
|
||||
///
|
||||
/// [T] must be a primitive type (String, int, double, bool, or a List or Map containing only these types).
|
||||
/// An error is not thrown if T is not one of these types, but compiled Conduit applications may fail at runtime.
|
||||
///
|
||||
/// Performance considerations:
|
||||
///
|
||||
/// The decoded value is retained, and subsequent invocations of this method return the
|
||||
/// retained value to avoid performing the decoding process again.
|
||||
Future<T> decode<T>() async {
|
||||
if (hasBeenDecoded) {
|
||||
return _cast<T>(_decodedData);
|
||||
}
|
||||
|
||||
final codec =
|
||||
CodecRegistry.defaultInstance.codecForContentType(contentType);
|
||||
final originalBytes = await _readBytes(bytes);
|
||||
|
||||
if (retainOriginalBytes) {
|
||||
_bytes = originalBytes;
|
||||
}
|
||||
|
||||
if (codec == null) {
|
||||
_decodedData = originalBytes;
|
||||
return _cast<T>(_decodedData);
|
||||
}
|
||||
|
||||
try {
|
||||
_decodedData = codec.decoder.convert(originalBytes);
|
||||
} on Response {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
throw Response.badRequest(
|
||||
body: {"error": "request entity could not be decoded"},
|
||||
);
|
||||
}
|
||||
|
||||
return _cast<T>(_decodedData);
|
||||
}
|
||||
|
||||
/// Returns previously decoded object as [T].
|
||||
///
|
||||
/// This method is the synchronous version of [decode]. However, [decode] must have been called
|
||||
/// prior to invoking this method or an error is thrown.
|
||||
T as<T>() {
|
||||
if (!hasBeenDecoded) {
|
||||
throw StateError("Attempted to access request body without decoding it.");
|
||||
}
|
||||
|
||||
return _cast<T>(_decodedData);
|
||||
}
|
||||
|
||||
T _cast<T>(dynamic body) {
|
||||
try {
|
||||
return RuntimeContext.current.coerce<T>(body);
|
||||
} on TypeCoercionException {
|
||||
throw Response.badRequest(
|
||||
body: {"error": "request entity was unexpected type"},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<int>> _readBytes(Stream<List<int>> stream) async {
|
||||
return (await stream.toList()).expand((e) => e).toList();
|
||||
}
|
||||
}
|
66
packages/http/lib/src/cache_policy.dart
Normal file
66
packages/http/lib/src/cache_policy.dart
Normal file
|
@ -0,0 +1,66 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Instances of this type provide configuration for the 'Cache-Control' header.
|
||||
///
|
||||
/// Typically used by [FileController]. See [FileController.addCachePolicy].
|
||||
class CachePolicy {
|
||||
/// Creates a new cache policy.
|
||||
///
|
||||
/// Policies applied to [Response.cachePolicy] will add the appropriate
|
||||
/// headers to that response. See properties for definitions of arguments
|
||||
/// to this constructor.
|
||||
const CachePolicy({
|
||||
this.preventIntermediateProxyCaching = false,
|
||||
this.preventCaching = false,
|
||||
this.requireConditionalRequest = false,
|
||||
this.expirationFromNow,
|
||||
});
|
||||
|
||||
/// Prevents a response from being cached by an intermediate proxy.
|
||||
///
|
||||
/// This sets 'Cache-Control: private' if true. Otherwise, 'Cache-Control: public' is used.
|
||||
final bool preventIntermediateProxyCaching;
|
||||
|
||||
/// Prevents any caching of a response by a proxy or client.
|
||||
///
|
||||
/// If true, sets 'Cache-Control: no-cache, no-store'. If this property is true,
|
||||
/// no other properties are evaluated.
|
||||
final bool preventCaching;
|
||||
|
||||
/// Requires a client to send a conditional GET to use a cached response.
|
||||
///
|
||||
/// If true, sets 'Cache-Control: no-cache'.
|
||||
final bool requireConditionalRequest;
|
||||
|
||||
/// Sets how long a resource is valid for.
|
||||
///
|
||||
/// Sets 'Cache-Control: max-age=x', where 'x' is [expirationFromNow] in seconds.
|
||||
final Duration? expirationFromNow;
|
||||
|
||||
/// Constructs a header value configured from this instance.
|
||||
///
|
||||
/// This value is used for the 'Cache-Control' header.
|
||||
String get headerValue {
|
||||
if (preventCaching) {
|
||||
return "no-cache, no-store";
|
||||
}
|
||||
|
||||
final items = [];
|
||||
|
||||
if (preventIntermediateProxyCaching) {
|
||||
items.add("private");
|
||||
} else {
|
||||
items.add("public");
|
||||
}
|
||||
|
||||
if (expirationFromNow != null) {
|
||||
items.add("max-age=${expirationFromNow!.inSeconds}");
|
||||
}
|
||||
|
||||
if (requireConditionalRequest) {
|
||||
items.add("no-cache");
|
||||
}
|
||||
|
||||
return items.join(", ");
|
||||
}
|
||||
}
|
467
packages/http/lib/src/controller.dart
Normal file
467
packages/http/lib/src/controller.dart
Normal file
|
@ -0,0 +1,467 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// The unifying protocol for [Request] and [Response] classes.
|
||||
///
|
||||
/// A [Controller] must return an instance of this type from its [Controller.handle] method.
|
||||
abstract class RequestOrResponse {}
|
||||
|
||||
/// An interface that [Controller] subclasses implement to generate a controller for each request.
|
||||
///
|
||||
/// If a [Controller] implements this interface, a [Controller] is created for each request. Controllers
|
||||
/// must implement this interface if they declare setters or non-final properties, as those properties could
|
||||
/// change during request handling.
|
||||
///
|
||||
/// A controller that implements this interface can store information that is not tied to the request
|
||||
/// to be reused across each instance of the controller type by implementing [recycledState] and [restore].
|
||||
/// Use these methods when a controller needs to construct runtime information that only needs to occur once
|
||||
/// per controller type.
|
||||
abstract class Recyclable<T> implements Controller {
|
||||
/// Returns state information that is reused across instances of this type.
|
||||
///
|
||||
/// This method is called once when this instance is first created. It is passed
|
||||
/// to each instance of this type via [restore].
|
||||
T? get recycledState;
|
||||
|
||||
/// Provides a instance of this type with the [recycledState] of this type.
|
||||
///
|
||||
/// Use this method it provide compiled runtime information to a instance.
|
||||
void restore(T? state);
|
||||
}
|
||||
|
||||
/// An interface for linking controllers.
|
||||
///
|
||||
/// All [Controller]s implement this interface.
|
||||
abstract class Linkable {
|
||||
/// See [Controller.link].
|
||||
Linkable? link(Controller Function() instantiator);
|
||||
|
||||
/// See [Controller.linkFunction].
|
||||
Linkable? linkFunction(
|
||||
FutureOr<RequestOrResponse?> Function(Request request) handle,
|
||||
);
|
||||
}
|
||||
|
||||
/// Base class for request handling objects.
|
||||
///
|
||||
/// A controller is a discrete processing unit for requests. These units are linked
|
||||
/// together to form a series of steps that fully handle a request.
|
||||
///
|
||||
/// Subclasses must implement [handle] to respond to, modify or forward requests.
|
||||
/// This class must be subclassed. [Router] and [ResourceController] are common subclasses.
|
||||
abstract class Controller
|
||||
implements APIComponentDocumenter, APIOperationDocumenter, Linkable {
|
||||
/// Returns a stacktrace and additional details about how the request's processing in the HTTP response.
|
||||
///
|
||||
/// By default, this is false. During debugging, setting this to true can help debug Conduit applications
|
||||
/// from the HTTP client.
|
||||
static bool includeErrorDetailsInServerErrorResponses = false;
|
||||
|
||||
/// Whether or not to allow uncaught exceptions escape request controllers.
|
||||
///
|
||||
/// When this value is false - the default - all [Controller] instances handle
|
||||
/// unexpected exceptions by catching and logging them, and then returning a 500 error.
|
||||
///
|
||||
/// While running tests, it is useful to know where unexpected exceptions come from because
|
||||
/// they are an error in your code. By setting this value to true, all [Controller]s
|
||||
/// will rethrow unexpected exceptions in addition to the base behavior. This allows the stack
|
||||
/// trace of the unexpected exception to appear in test results and halt the tests with failure.
|
||||
///
|
||||
/// By default, this value is false. Do not set this value to true outside of tests.
|
||||
static bool letUncaughtExceptionsEscape = false;
|
||||
|
||||
/// Receives requests that this controller does not respond to.
|
||||
///
|
||||
/// This value is set by [link] or [linkFunction].
|
||||
Controller? get nextController => _nextController;
|
||||
|
||||
/// An instance of the 'conduit' logger.
|
||||
Logger get logger => Logger("conduit");
|
||||
|
||||
/// The CORS policy of this controller.
|
||||
CORSPolicy? policy = CORSPolicy();
|
||||
|
||||
Controller? _nextController;
|
||||
|
||||
/// Links a controller to the receiver to form a request channel.
|
||||
///
|
||||
/// Establishes a channel containing the receiver and the controller returned by [instantiator]. If
|
||||
/// the receiver does not handle a request, the controller created by [instantiator] will get an opportunity to do so.
|
||||
///
|
||||
/// [instantiator] is called immediately when invoking this function. If the returned [Controller] does not implement
|
||||
/// [Recyclable], this is the only time [instantiator] is called. The returned controller must only have properties that
|
||||
/// are marked as final.
|
||||
///
|
||||
/// If the returned controller has properties that are not marked as final, it must implement [Recyclable].
|
||||
/// When a controller implements [Recyclable], [instantiator] is called for each request that
|
||||
/// reaches this point of the channel. See [Recyclable] for more details.
|
||||
///
|
||||
/// See [linkFunction] for a variant of this method that takes a closure instead of an object.
|
||||
@override
|
||||
Linkable link(Controller Function() instantiator) {
|
||||
final instance = instantiator();
|
||||
if (instance is Recyclable) {
|
||||
_nextController = _ControllerRecycler(instantiator, instance);
|
||||
} else {
|
||||
_nextController = instantiator();
|
||||
}
|
||||
|
||||
return _nextController!;
|
||||
}
|
||||
|
||||
/// Links a function controller to the receiver to form a request channel.
|
||||
///
|
||||
/// If the receiver does not respond to a request, [handle] receives the request next.
|
||||
///
|
||||
/// See [link] for a variant of this method that takes an object instead of a closure.
|
||||
@override
|
||||
Linkable? linkFunction(
|
||||
FutureOr<RequestOrResponse?> Function(Request request) handle,
|
||||
) {
|
||||
return _nextController = _FunctionController(handle);
|
||||
}
|
||||
|
||||
/// Lifecycle callback, invoked after added to channel, but before any requests are served.
|
||||
///
|
||||
/// Subclasses override this method to provide final, one-time initialization after it has been added to a channel,
|
||||
/// but before any requests are served. This is useful for performing any caching or optimizations for this instance.
|
||||
/// For example, [Router] overrides this method to optimize its list of routes into a more efficient data structure.
|
||||
///
|
||||
/// This method is invoked immediately after [ApplicationChannel.entryPoint] completes, for each
|
||||
/// instance in the channel created by [ApplicationChannel.entryPoint]. This method will only be called once per instance.
|
||||
///
|
||||
/// Controllers added to the channel via [link] may use this method, but any values this method stores
|
||||
/// must be stored in a static structure, not the instance itself, since that instance will only be used to handle one request
|
||||
/// before it is garbage collected.
|
||||
///
|
||||
/// If you override this method you should call the superclass' implementation so that linked controllers invoke this same method.
|
||||
/// If you do not invoke the superclass' implementation, you must ensure that any linked controllers invoked this method through other means.
|
||||
void didAddToChannel() {
|
||||
_nextController?.didAddToChannel();
|
||||
}
|
||||
|
||||
/// Delivers [req] to this instance to be processed.
|
||||
///
|
||||
/// This method is the entry point of a [Request] into this [Controller].
|
||||
/// By default, it invokes this controller's [handle] method within a try-catch block
|
||||
/// that guarantees an HTTP response will be sent for [Request].
|
||||
Future? receive(Request req) async {
|
||||
if (req.isPreflightRequest) {
|
||||
return _handlePreflightRequest(req);
|
||||
}
|
||||
|
||||
Request? next;
|
||||
try {
|
||||
try {
|
||||
final result = await handle(req);
|
||||
if (result is Response) {
|
||||
await _sendResponse(req, result, includeCORSHeaders: true);
|
||||
logger.info(req.toDebugString());
|
||||
return null;
|
||||
} else if (result is Request) {
|
||||
next = result;
|
||||
}
|
||||
} on Response catch (response) {
|
||||
await _sendResponse(req, response, includeCORSHeaders: true);
|
||||
logger.info(req.toDebugString());
|
||||
return null;
|
||||
} on HandlerException catch (e) {
|
||||
await _sendResponse(req, e.response, includeCORSHeaders: true);
|
||||
logger.info(req.toDebugString());
|
||||
return null;
|
||||
}
|
||||
} catch (any, stacktrace) {
|
||||
handleError(req, any, stacktrace);
|
||||
|
||||
if (letUncaughtExceptionsEscape) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (next == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nextController?.receive(next);
|
||||
}
|
||||
|
||||
/// The primary request handling method of this object.
|
||||
///
|
||||
/// Subclasses implement this method to provide their request handling logic.
|
||||
///
|
||||
/// If this method returns a [Response], it will be sent as the response for [request] linked controllers will not handle it.
|
||||
///
|
||||
/// If this method returns [request], the linked controller handles the request.
|
||||
///
|
||||
/// If this method returns null, [request] is not passed to any other controller and is not responded to. You must respond to [request]
|
||||
/// through [Request.raw].
|
||||
FutureOr<RequestOrResponse?> handle(Request request);
|
||||
|
||||
/// Executed prior to [Response] being sent.
|
||||
///
|
||||
/// This method is used to post-process [response] just before it is sent. By default, does nothing.
|
||||
/// The [response] may be altered prior to being sent. This method will be executed for all requests,
|
||||
/// including server errors.
|
||||
void willSendResponse(Response response) {}
|
||||
|
||||
/// Sends an HTTP response for a request that yields an exception or error.
|
||||
///
|
||||
/// When this controller encounters an exception or error while handling [request], this method is called to send the response.
|
||||
/// By default, it attempts to send a 500 Server Error response and logs the error and stack trace to [logger].
|
||||
///
|
||||
/// Note: If [caughtValue]'s implements [HandlerException], this method is not called.
|
||||
///
|
||||
/// If you override this method, it must not throw.
|
||||
Future handleError(
|
||||
Request request,
|
||||
dynamic caughtValue,
|
||||
StackTrace trace,
|
||||
) async {
|
||||
if (caughtValue is HTTPStreamingException) {
|
||||
logger.severe(
|
||||
request.toDebugString(includeHeaders: true),
|
||||
caughtValue.underlyingException,
|
||||
caughtValue.trace,
|
||||
);
|
||||
|
||||
request.response.close().catchError((_) => null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final body = includeErrorDetailsInServerErrorResponses
|
||||
? {
|
||||
"controller": "$runtimeType",
|
||||
"error": "$caughtValue.",
|
||||
"stacktrace": trace.toString()
|
||||
}
|
||||
: null;
|
||||
|
||||
final response = Response.serverError(body: body)
|
||||
..contentType = ContentType.json;
|
||||
|
||||
await _sendResponse(request, response, includeCORSHeaders: true);
|
||||
|
||||
logger.severe(
|
||||
request.toDebugString(includeHeaders: true),
|
||||
caughtValue,
|
||||
trace,
|
||||
);
|
||||
} catch (e) {
|
||||
logger.severe("Failed to send response, draining request. Reason: $e");
|
||||
|
||||
request.raw.drain().catchError((_) => null);
|
||||
}
|
||||
}
|
||||
|
||||
void applyCORSHeadersIfNecessary(Request req, Response resp) {
|
||||
if (req.isCORSRequest && !req.isPreflightRequest) {
|
||||
final lastPolicyController = _lastController;
|
||||
final p = lastPolicyController.policy;
|
||||
if (p != null) {
|
||||
if (p.isRequestOriginAllowed(req.raw)) {
|
||||
resp.headers.addAll(p.headersForRequest(req));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIPath> documentPaths(APIDocumentContext context) =>
|
||||
nextController?.documentPaths(context) ?? {};
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
if (nextController == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return nextController!.documentOperations(context, route, path);
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) =>
|
||||
nextController?.documentComponents(context);
|
||||
|
||||
Future? _handlePreflightRequest(Request req) async {
|
||||
Controller controllerToDictatePolicy;
|
||||
try {
|
||||
final lastControllerInChain = _lastController;
|
||||
if (lastControllerInChain != this) {
|
||||
controllerToDictatePolicy = lastControllerInChain;
|
||||
} else {
|
||||
if (policy != null) {
|
||||
if (!policy!.validatePreflightRequest(req.raw)) {
|
||||
await _sendResponse(req, Response.forbidden());
|
||||
logger.info(req.toDebugString(includeHeaders: true));
|
||||
} else {
|
||||
await _sendResponse(req, policy!.preflightResponse(req));
|
||||
logger.info(req.toDebugString());
|
||||
}
|
||||
|
||||
return null;
|
||||
} else {
|
||||
// If we don't have a policy, then a preflight request makes no sense.
|
||||
await _sendResponse(req, Response.forbidden());
|
||||
logger.info(req.toDebugString(includeHeaders: true));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (any, stacktrace) {
|
||||
return handleError(req, any, stacktrace);
|
||||
}
|
||||
|
||||
return controllerToDictatePolicy.receive(req);
|
||||
}
|
||||
|
||||
Future _sendResponse(
|
||||
Request request,
|
||||
Response response, {
|
||||
bool includeCORSHeaders = false,
|
||||
}) {
|
||||
if (includeCORSHeaders) {
|
||||
applyCORSHeadersIfNecessary(request, response);
|
||||
}
|
||||
willSendResponse(response);
|
||||
|
||||
return request.respond(response);
|
||||
}
|
||||
|
||||
Controller get _lastController {
|
||||
Controller controller = this;
|
||||
while (controller.nextController != null) {
|
||||
controller = controller.nextController!;
|
||||
}
|
||||
return controller;
|
||||
}
|
||||
}
|
||||
|
||||
@PreventCompilation()
|
||||
class _ControllerRecycler<T> extends Controller {
|
||||
_ControllerRecycler(this.generator, Recyclable<T> instance) {
|
||||
recycleState = instance.recycledState;
|
||||
nextInstanceToReceive = instance;
|
||||
}
|
||||
|
||||
Controller Function() generator;
|
||||
CORSPolicy? policyOverride;
|
||||
T? recycleState;
|
||||
|
||||
Recyclable<T>? _nextInstanceToReceive;
|
||||
|
||||
Recyclable<T>? get nextInstanceToReceive => _nextInstanceToReceive;
|
||||
|
||||
set nextInstanceToReceive(Recyclable<T>? instance) {
|
||||
_nextInstanceToReceive = instance;
|
||||
instance?.restore(recycleState);
|
||||
instance?._nextController = nextController;
|
||||
if (policyOverride != null) {
|
||||
instance?.policy = policyOverride;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
CORSPolicy? get policy {
|
||||
return nextInstanceToReceive?.policy;
|
||||
}
|
||||
|
||||
@override
|
||||
set policy(CORSPolicy? p) {
|
||||
policyOverride = p;
|
||||
}
|
||||
|
||||
@override
|
||||
Linkable link(Controller Function() instantiator) {
|
||||
final c = super.link(instantiator);
|
||||
nextInstanceToReceive?._nextController = c as Controller;
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
Linkable? linkFunction(
|
||||
FutureOr<RequestOrResponse?> Function(Request request) handle,
|
||||
) {
|
||||
final c = super.linkFunction(handle);
|
||||
nextInstanceToReceive?._nextController = c as Controller?;
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
Future? receive(Request req) {
|
||||
final next = nextInstanceToReceive;
|
||||
nextInstanceToReceive = generator() as Recyclable<T>;
|
||||
return next!.receive(req);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> handle(Request request) {
|
||||
throw StateError("_ControllerRecycler invoked handle. This is a bug.");
|
||||
}
|
||||
|
||||
@override
|
||||
void didAddToChannel() {
|
||||
// don't call super, since nextInstanceToReceive's nextController is set to the same instance,
|
||||
// and it must call nextController.prepare
|
||||
nextInstanceToReceive?.didAddToChannel();
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext components) =>
|
||||
nextInstanceToReceive?.documentComponents(components);
|
||||
|
||||
@override
|
||||
Map<String, APIPath> documentPaths(APIDocumentContext components) =>
|
||||
nextInstanceToReceive?.documentPaths(components) ?? {};
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext components,
|
||||
String route,
|
||||
APIPath path,
|
||||
) =>
|
||||
nextInstanceToReceive?.documentOperations(components, route, path) ?? {};
|
||||
}
|
||||
|
||||
@PreventCompilation()
|
||||
class _FunctionController extends Controller {
|
||||
_FunctionController(this._handler);
|
||||
|
||||
final FutureOr<RequestOrResponse?> Function(Request) _handler;
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse?> handle(Request request) {
|
||||
return _handler(request);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
if (nextController == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return nextController!.documentOperations(context, route, path);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ControllerRuntime {
|
||||
bool get isMutable;
|
||||
|
||||
ResourceControllerRuntime? get resourceController;
|
||||
}
|
201
packages/http/lib/src/cors_policy.dart
Normal file
201
packages/http/lib/src/cors_policy.dart
Normal file
|
@ -0,0 +1,201 @@
|
|||
import 'dart:io';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Describes a CORS policy for a [Controller].
|
||||
///
|
||||
/// A CORS policy describes allowed origins, accepted HTTP methods and headers, exposed response headers
|
||||
/// and other values used by browsers to manage XHR requests to a Conduit application.
|
||||
///
|
||||
/// Every [Controller] has a [Controller.policy]. By default, this value is [defaultPolicy], which is quite permissive.
|
||||
///
|
||||
/// Modifications to policy for a specific [Controller] can be accomplished in the initializer of the controller.
|
||||
///
|
||||
/// Application-wide defaults can be managed by modifying [defaultPolicy] in a [ApplicationChannel]'s constructor.
|
||||
///
|
||||
class CORSPolicy {
|
||||
/// Create a new instance of [CORSPolicy].
|
||||
///
|
||||
/// Values are set to match [defaultPolicy].
|
||||
CORSPolicy() {
|
||||
final def = defaultPolicy;
|
||||
allowedOrigins = List.from(def.allowedOrigins);
|
||||
allowCredentials = def.allowCredentials;
|
||||
exposedResponseHeaders = List.from(def.exposedResponseHeaders);
|
||||
allowedMethods = List.from(def.allowedMethods);
|
||||
allowedRequestHeaders = List.from(def.allowedRequestHeaders);
|
||||
cacheInSeconds = def.cacheInSeconds;
|
||||
}
|
||||
|
||||
CORSPolicy._defaults() {
|
||||
allowedOrigins = ["*"];
|
||||
allowCredentials = true;
|
||||
exposedResponseHeaders = [];
|
||||
allowedMethods = ["POST", "PUT", "DELETE", "GET"];
|
||||
allowedRequestHeaders = [
|
||||
"origin",
|
||||
"authorization",
|
||||
"x-requested-with",
|
||||
"x-forwarded-for",
|
||||
"content-type"
|
||||
];
|
||||
cacheInSeconds = 86400;
|
||||
}
|
||||
|
||||
/// The default CORS policy.
|
||||
///
|
||||
/// You may modify this default policy. All instances of [CORSPolicy] are instantiated
|
||||
/// using the values of this default policy. Do not modify this property
|
||||
/// unless you want the defaults to change application-wide.
|
||||
static CORSPolicy get defaultPolicy {
|
||||
return _defaultPolicy ??= CORSPolicy._defaults();
|
||||
}
|
||||
|
||||
static CORSPolicy? _defaultPolicy;
|
||||
|
||||
/// List of 'Simple' CORS headers.
|
||||
///
|
||||
/// These are headers that are considered acceptable as part of any CORS request and cannot be changed.
|
||||
static const List<String> simpleRequestHeaders = [
|
||||
"accept",
|
||||
"accept-language",
|
||||
"content-language",
|
||||
"content-type"
|
||||
];
|
||||
|
||||
/// List of 'Simple' CORS Response headers.
|
||||
///
|
||||
/// These headers can be returned in a response without explicitly exposing them and cannot be changed.
|
||||
static const List<String> simpleResponseHeaders = [
|
||||
"cache-control",
|
||||
"content-language",
|
||||
"content-type",
|
||||
"content-type",
|
||||
"expires",
|
||||
"last-modified",
|
||||
"pragma"
|
||||
];
|
||||
|
||||
/// The list of case-sensitive allowed origins.
|
||||
///
|
||||
/// Defaults to '*'. Case-sensitive. In the specification (http://www.w3.org/TR/cors/), this is 'list of origins'.
|
||||
late List<String> allowedOrigins;
|
||||
|
||||
/// Whether or not to allow use of credentials, including Authorization and cookies.
|
||||
///
|
||||
/// Defaults to true. In the specification (http://www.w3.org/TR/cors/), this is 'supports credentials'.
|
||||
late bool allowCredentials;
|
||||
|
||||
/// Which response headers to expose to the client.
|
||||
///
|
||||
/// Defaults to empty. In the specification (http://www.w3.org/TR/cors/), this is 'list of exposed headers'.
|
||||
///
|
||||
///
|
||||
late List<String> exposedResponseHeaders;
|
||||
|
||||
/// Which HTTP methods are allowed.
|
||||
///
|
||||
/// Defaults to POST, PUT, DELETE, and GET. Case-sensitive. In the specification (http://www.w3.org/TR/cors/), this is 'list of methods'.
|
||||
late List<String> allowedMethods;
|
||||
|
||||
/// The allowed request headers.
|
||||
///
|
||||
/// Defaults to authorization, x-requested-with, x-forwarded-for. Must be lowercase.
|
||||
/// Use in conjunction with [simpleRequestHeaders]. In the specification (http://www.w3.org/TR/cors/), this is 'list of headers'.
|
||||
late List<String> allowedRequestHeaders;
|
||||
|
||||
/// The number of seconds to cache a pre-flight request for a requesting client.
|
||||
int? cacheInSeconds;
|
||||
|
||||
/// Returns a map of HTTP headers for a request based on this policy.
|
||||
///
|
||||
/// This will add Access-Control-Allow-Origin, Access-Control-Expose-Headers and Access-Control-Allow-Credentials
|
||||
/// depending on the this policy.
|
||||
Map<String, dynamic> headersForRequest(Request request) {
|
||||
final origin = request.raw.headers.value("origin");
|
||||
|
||||
final headers = <String, dynamic>{};
|
||||
headers["Access-Control-Allow-Origin"] = origin;
|
||||
|
||||
if (exposedResponseHeaders.isNotEmpty) {
|
||||
headers["Access-Control-Expose-Headers"] =
|
||||
exposedResponseHeaders.join(", ");
|
||||
}
|
||||
|
||||
if (allowCredentials) {
|
||||
headers["Access-Control-Allow-Credentials"] = "true";
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/// Whether or not this policy allows the Origin of the [request].
|
||||
///
|
||||
/// Will return true if [allowedOrigins] contains the case-sensitive Origin of the [request],
|
||||
/// or that [allowedOrigins] contains *.
|
||||
/// This method is invoked internally by [Controller]s that have a [Controller.policy].
|
||||
bool isRequestOriginAllowed(HttpRequest request) {
|
||||
if (allowedOrigins.contains("*")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final origin = request.headers.value("origin");
|
||||
if (allowedOrigins.contains(origin)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Validates whether or not a preflight request matches this policy.
|
||||
///
|
||||
/// Will return true if the policy agrees with the Access-Control-Request-* headers of the request, otherwise, false.
|
||||
/// This method is invoked internally by [Controller]s that have a [Controller.policy].
|
||||
bool validatePreflightRequest(HttpRequest request) {
|
||||
if (!isRequestOriginAllowed(request)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final method = request.headers.value("access-control-request-method");
|
||||
if (!allowedMethods.contains(method)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final requestedHeaders = request.headers
|
||||
.value("access-control-request-headers")
|
||||
?.split(",")
|
||||
.map((str) => str.trim().toLowerCase())
|
||||
.toList();
|
||||
if (requestedHeaders?.isNotEmpty ?? false) {
|
||||
final nonSimpleHeaders =
|
||||
requestedHeaders!.where((str) => !simpleRequestHeaders.contains(str));
|
||||
if (nonSimpleHeaders.any((h) => !allowedRequestHeaders.contains(h))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Returns a preflight response for a given [Request].
|
||||
///
|
||||
/// Contains the Access-Control-Allow-* headers for a CORS preflight request according
|
||||
/// to this policy.
|
||||
/// This method is invoked internally by [Controller]s that have a [Controller.policy].
|
||||
Response preflightResponse(Request req) {
|
||||
final headers = {
|
||||
"Access-Control-Allow-Origin": req.raw.headers.value("origin"),
|
||||
"Access-Control-Allow-Methods": allowedMethods.join(", "),
|
||||
"Access-Control-Allow-Headers": allowedRequestHeaders.join(", ")
|
||||
};
|
||||
|
||||
if (allowCredentials) {
|
||||
headers["Access-Control-Allow-Credentials"] = "true";
|
||||
}
|
||||
|
||||
if (cacheInSeconds != null) {
|
||||
headers["Access-Control-Max-Age"] = "$cacheInSeconds";
|
||||
}
|
||||
|
||||
return Response.ok(null, headers: headers);
|
||||
}
|
||||
}
|
241
packages/http/lib/src/file_controller.dart
Normal file
241
packages/http/lib/src/file_controller.dart
Normal file
|
@ -0,0 +1,241 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
typedef FileControllerClosure = FutureOr<Response> Function(
|
||||
FileController controller,
|
||||
Request req,
|
||||
);
|
||||
|
||||
/// Serves files from a directory on the filesystem.
|
||||
///
|
||||
/// See the constructor for usage.
|
||||
class FileController extends Controller {
|
||||
/// Creates a controller that serves files from [pathOfDirectoryToServe].
|
||||
///
|
||||
/// File controllers append the path of an HTTP request to [pathOfDirectoryToServe] and attempt to read the file at that location.
|
||||
///
|
||||
/// If the file exists, its contents are sent in the HTTP Response body. If the file does not exist, a 404 Not Found error is returned by default.
|
||||
///
|
||||
/// A route to this controller must contain the match-all segment (`*`). For example:
|
||||
///
|
||||
/// router
|
||||
/// .route("/site/*")
|
||||
/// .link(() => FileController("build/web"));
|
||||
///
|
||||
/// In the above, `GET /site/index.html` would return the file `build/web/index.html`.
|
||||
///
|
||||
/// If [pathOfDirectoryToServe] contains a leading slash, it is an absolute path. Otherwise, it is relative to the current working directory
|
||||
/// of the running application.
|
||||
///
|
||||
/// If no file is found, the default behavior is to return a 404 Not Found. (If the [Request] accepts 'text/html', a simple 404 page is returned.) You may
|
||||
/// override this behavior by providing [onFileNotFound].
|
||||
///
|
||||
/// The content type of the response is determined by the file extension of the served file. There are many built-in extension-to-content-type mappings and you may
|
||||
/// add more with [setContentTypeForExtension]. Unknown file extension will result in `application/octet-stream` content-type responses.
|
||||
///
|
||||
/// The contents of a file will be compressed with 'gzip' if the request allows for it and the content-type of the file can be compressed
|
||||
/// according to [CodecRegistry].
|
||||
///
|
||||
/// Note that the 'Last-Modified' header is always applied to a response served from this instance.
|
||||
FileController(
|
||||
String pathOfDirectoryToServe, {
|
||||
FileControllerClosure? onFileNotFound,
|
||||
}) : _servingDirectory = Uri.directory(pathOfDirectoryToServe),
|
||||
_onFileNotFound = onFileNotFound;
|
||||
|
||||
static final Map<String, ContentType> _defaultExtensionMap = {
|
||||
/* Web content */
|
||||
"html": ContentType("text", "html", charset: "utf-8"),
|
||||
"css": ContentType("text", "css", charset: "utf-8"),
|
||||
"js": ContentType("application", "javascript", charset: "utf-8"),
|
||||
"json": ContentType("application", "json", charset: "utf-8"),
|
||||
|
||||
/* Images */
|
||||
"jpg": ContentType("image", "jpeg"),
|
||||
"jpeg": ContentType("image", "jpeg"),
|
||||
"eps": ContentType("application", "postscript"),
|
||||
"png": ContentType("image", "png"),
|
||||
"gif": ContentType("image", "gif"),
|
||||
"bmp": ContentType("image", "bmp"),
|
||||
"tiff": ContentType("image", "tiff"),
|
||||
"tif": ContentType("image", "tiff"),
|
||||
"ico": ContentType("image", "x-icon"),
|
||||
"svg": ContentType("image", "svg+xml"),
|
||||
|
||||
/* Documents */
|
||||
"rtf": ContentType("application", "rtf"),
|
||||
"pdf": ContentType("application", "pdf"),
|
||||
"csv": ContentType("text", "plain", charset: "utf-8"),
|
||||
"md": ContentType("text", "plain", charset: "utf-8"),
|
||||
|
||||
/* Fonts */
|
||||
"ttf": ContentType("font", "ttf"),
|
||||
"eot": ContentType("application", "vnd.ms-fontobject"),
|
||||
"woff": ContentType("font", "woff"),
|
||||
"otf": ContentType("font", "otf"),
|
||||
};
|
||||
|
||||
final Map<String, ContentType> _extensionMap = Map.from(_defaultExtensionMap);
|
||||
final List<_PolicyPair?> _policyPairs = [];
|
||||
final Uri _servingDirectory;
|
||||
final FutureOr<Response> Function(
|
||||
FileController,
|
||||
Request,
|
||||
)? _onFileNotFound;
|
||||
|
||||
/// Returns a [ContentType] for a file extension.
|
||||
///
|
||||
/// Returns the associated content type for [extension], if one exists. Extension may have leading '.',
|
||||
/// e.g. both '.jpg' and 'jpg' are valid inputs to this method.
|
||||
///
|
||||
/// Returns null if there is no entry for [extension]. Entries can be added with [setContentTypeForExtension].
|
||||
ContentType? contentTypeForExtension(String extension) {
|
||||
if (extension.startsWith(".")) {
|
||||
return _extensionMap[extension.substring(1)];
|
||||
}
|
||||
return _extensionMap[extension];
|
||||
}
|
||||
|
||||
/// Sets the associated content type for a file extension.
|
||||
///
|
||||
/// When a file with [extension] file extension is served by any instance of this type,
|
||||
/// the [contentType] will be sent as the response's Content-Type header.
|
||||
void setContentTypeForExtension(String extension, ContentType contentType) {
|
||||
_extensionMap[extension] = contentType;
|
||||
}
|
||||
|
||||
/// Add a cache policy for file paths that return true for [shouldApplyToPath].
|
||||
///
|
||||
/// When this instance serves a file, the headers determined by [policy]
|
||||
/// will be applied to files whose path returns true for [shouldApplyToPath].
|
||||
///
|
||||
/// If a path would meet the criteria for multiple [shouldApplyToPath] functions added to this instance,
|
||||
/// the policy added earliest to this instance will be applied.
|
||||
///
|
||||
/// For example, the following adds a set of cache policies that will apply 'Cache-Control: no-cache, no-store' to '.widget' files,
|
||||
/// and 'Cache-Control: public' for any other files:
|
||||
///
|
||||
/// fileController.addCachePolicy(const CachePolicy(preventCaching: true),
|
||||
/// (p) => p.endsWith(".widget"));
|
||||
/// fileController.addCachePolicy(const CachePolicy(),
|
||||
/// (p) => true);
|
||||
///
|
||||
/// Whereas the following incorrect example would apply 'Cache-Control: public' to '.widget' files because the first policy
|
||||
/// would always apply to it and the second policy would be ignored:
|
||||
///
|
||||
/// fileController.addCachePolicy(const CachePolicy(),
|
||||
/// (p) => true);
|
||||
/// fileController.addCachePolicy(const CachePolicy(preventCaching: true),
|
||||
/// (p) => p.endsWith(".widget"));
|
||||
///
|
||||
/// Note that the 'Last-Modified' header is always applied to a response served from this instance.
|
||||
///
|
||||
void addCachePolicy(
|
||||
CachePolicy policy,
|
||||
bool Function(String path) shouldApplyToPath,
|
||||
) {
|
||||
_policyPairs.add(_PolicyPair(policy, shouldApplyToPath));
|
||||
}
|
||||
|
||||
/// Returns the [CachePolicy] for [path].
|
||||
///
|
||||
/// Evaluates each policy added by [addCachePolicy] against the [path] and
|
||||
/// returns it if exists.
|
||||
CachePolicy? cachePolicyForPath(String path) {
|
||||
return _policyPairs
|
||||
.firstWhere(
|
||||
(pair) => pair?.shouldApplyToPath(path) ?? false,
|
||||
orElse: () => null,
|
||||
)
|
||||
?.policy;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RequestOrResponse> handle(Request request) async {
|
||||
if (request.method != "GET") {
|
||||
return Response(HttpStatus.methodNotAllowed, null, null);
|
||||
}
|
||||
|
||||
final relativePath = request.path.remainingPath;
|
||||
final fileUri = _servingDirectory.resolve(relativePath ?? "");
|
||||
File file;
|
||||
if (FileSystemEntity.isDirectorySync(fileUri.toFilePath())) {
|
||||
file = File.fromUri(fileUri.resolve("index.html"));
|
||||
} else {
|
||||
file = File.fromUri(fileUri);
|
||||
}
|
||||
|
||||
if (!file.existsSync()) {
|
||||
if (_onFileNotFound != null) {
|
||||
return _onFileNotFound(this, request);
|
||||
}
|
||||
|
||||
final response = Response.notFound();
|
||||
if (request.acceptsContentType(ContentType.html)) {
|
||||
response
|
||||
..body = "<html><h3>404 Not Found</h3></html>"
|
||||
..contentType = ContentType.html;
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
final lastModifiedDate = file.lastModifiedSync();
|
||||
final ifModifiedSince =
|
||||
request.raw.headers.value(HttpHeaders.ifModifiedSinceHeader);
|
||||
if (ifModifiedSince != null) {
|
||||
final date = HttpDate.parse(ifModifiedSince);
|
||||
if (!lastModifiedDate.isAfter(date)) {
|
||||
return Response.notModified(lastModifiedDate, _policyForFile(file));
|
||||
}
|
||||
}
|
||||
|
||||
final lastModifiedDateStringValue = HttpDate.format(lastModifiedDate);
|
||||
final contentType = contentTypeForExtension(path.extension(file.path)) ??
|
||||
ContentType("application", "octet-stream");
|
||||
final byteStream = file.openRead();
|
||||
|
||||
return Response.ok(
|
||||
byteStream,
|
||||
headers: {HttpHeaders.lastModifiedHeader: lastModifiedDateStringValue},
|
||||
)
|
||||
..cachePolicy = _policyForFile(file)
|
||||
..encodeBody = false
|
||||
..contentType = contentType;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
return {
|
||||
"get": APIOperation(
|
||||
"getFile",
|
||||
{
|
||||
"200": APIResponse(
|
||||
"Successful file fetch.",
|
||||
content: {"*/*": APIMediaType(schema: APISchemaObject.file())},
|
||||
),
|
||||
"404": APIResponse("No file exists at path.")
|
||||
},
|
||||
description: "Content-Type is determined by the suffix of the file.",
|
||||
summary: "Returns the contents of a file on the server's filesystem.",
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
CachePolicy? _policyForFile(File file) => cachePolicyForPath(file.path);
|
||||
}
|
||||
|
||||
class _PolicyPair {
|
||||
_PolicyPair(this.policy, this.shouldApplyToPath);
|
||||
|
||||
final bool Function(String) shouldApplyToPath;
|
||||
final CachePolicy policy;
|
||||
}
|
9
packages/http/lib/src/handler_exception.dart
Normal file
9
packages/http/lib/src/handler_exception.dart
Normal file
|
@ -0,0 +1,9 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
class HandlerException implements Exception {
|
||||
HandlerException(this._response);
|
||||
|
||||
Response get response => _response;
|
||||
|
||||
final Response _response;
|
||||
}
|
261
packages/http/lib/src/http_codec_repository.dart
Normal file
261
packages/http/lib/src/http_codec_repository.dart
Normal file
|
@ -0,0 +1,261 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Provides encoding and decoding services based on the [ContentType] of a [Request] or [Response].
|
||||
///
|
||||
/// The [defaultInstance] provides a lookup table of [ContentType] to [Codec]. By default,
|
||||
/// 'application/json', 'application/x-www-form-urlencoded' and 'text/*' content types have codecs and can
|
||||
/// transform a [Response.body] into a list of bytes that can be transferred as an HTTP response body.
|
||||
///
|
||||
/// Additional mappings are added via [add]. This method must be called per-isolate and it is recommended
|
||||
/// to add mappings in an application's [ApplicationChannel] subclass constructor.
|
||||
class CodecRegistry {
|
||||
CodecRegistry._() {
|
||||
add(
|
||||
ContentType("application", "json", charset: "utf-8"),
|
||||
const JsonCodec(),
|
||||
);
|
||||
add(
|
||||
ContentType("application", "x-www-form-urlencoded", charset: "utf-8"),
|
||||
const _FormCodec(),
|
||||
);
|
||||
setAllowsCompression(ContentType("text", "*"), true);
|
||||
setAllowsCompression(ContentType("application", "javascript"), true);
|
||||
setAllowsCompression(ContentType("text", "event-stream"), false);
|
||||
}
|
||||
|
||||
/// The instance used by Conduit to encode and decode HTTP bodies.
|
||||
///
|
||||
/// Custom codecs must be added to this instance. This value is guaranteed to be non-null.
|
||||
static CodecRegistry get defaultInstance => _defaultInstance;
|
||||
static final CodecRegistry _defaultInstance = CodecRegistry._();
|
||||
|
||||
final Map<String, Codec> _primaryTypeCodecs = {};
|
||||
final Map<String, Map<String, Codec>> _fullySpecificedCodecs = {};
|
||||
final Map<String, bool> _primaryTypeCompressionMap = {};
|
||||
final Map<String, Map<String, bool>> _fullySpecifiedCompressionMap = {};
|
||||
final Map<String, Map<String, String?>> _defaultCharsetMap = {};
|
||||
|
||||
/// Adds a custom [codec] for [contentType].
|
||||
///
|
||||
/// The body of a [Response] sent with [contentType] will be transformed by [codec]. A [Request] with [contentType] Content-Type
|
||||
/// will be decode its [Request.body] with [codec].
|
||||
///
|
||||
/// [codec] must produce a [List<int>] (or used chunked conversion to create a `Stream<List<int>>`).
|
||||
///
|
||||
/// [contentType]'s subtype may be `*`; all Content-Type's with a matching [ContentType.primaryType] will be
|
||||
/// encoded or decoded by [codec], regardless of [ContentType.subType]. For example, if [contentType] is `text/*`, then all
|
||||
/// `text/` (`text/html`, `text/plain`, etc.) content types are converted by [codec].
|
||||
///
|
||||
/// The most specific codec for a content type is chosen when converting an HTTP body. For example, if both `text/*`
|
||||
/// and `text/html` have been added through this method, a [Response] with content type `text/html` will select the codec
|
||||
/// associated with `text/html` and not `text/*`.
|
||||
///
|
||||
/// [allowCompression] chooses whether or not response bodies are compressed with [gzip] when using [contentType].
|
||||
/// Media types like images and audio files should avoid setting [allowCompression] because they are already compressed.
|
||||
///
|
||||
/// A response with a content type not in this instance will be sent unchanged to the HTTP client (and therefore must be [List<int>]
|
||||
///
|
||||
/// The [ContentType.charset] is not evaluated when selecting the codec for a content type. However, a charset indicates the default
|
||||
/// used when a request's Content-Type header omits a charset. For example, in order to decode JSON data, the request body must first be decoded
|
||||
/// from a list of bytes into a [String]. If a request omits the charset, this first step is would not be applied and the JSON codec would attempt
|
||||
/// to decode a list of bytes instead of a [String] and would fail. Thus, `application/json` is added through the following:
|
||||
///
|
||||
/// CodecRegistry.defaultInstance.add(
|
||||
/// ContentType("application", "json", charset: "utf-8"), const JsonCodec(), allowsCompression: true);
|
||||
///
|
||||
/// In the event that a request is sent without a charset, the codec will automatically apply a UTF8 decode step because of this default.
|
||||
///
|
||||
/// Only use default charsets when the codec must first be decoded into a [String].
|
||||
void add(
|
||||
ContentType contentType,
|
||||
Codec codec, {
|
||||
bool allowCompression = true,
|
||||
}) {
|
||||
if (contentType.subType == "*") {
|
||||
_primaryTypeCodecs[contentType.primaryType] = codec;
|
||||
_primaryTypeCompressionMap[contentType.primaryType] = allowCompression;
|
||||
} else {
|
||||
final innerCodecs = _fullySpecificedCodecs[contentType.primaryType] ?? {};
|
||||
innerCodecs[contentType.subType] = codec;
|
||||
_fullySpecificedCodecs[contentType.primaryType] = innerCodecs;
|
||||
|
||||
final innerCompress =
|
||||
_fullySpecifiedCompressionMap[contentType.primaryType] ?? {};
|
||||
innerCompress[contentType.subType] = allowCompression;
|
||||
_fullySpecifiedCompressionMap[contentType.primaryType] = innerCompress;
|
||||
}
|
||||
|
||||
if (contentType.charset != null) {
|
||||
final innerCodecs = _defaultCharsetMap[contentType.primaryType] ?? {};
|
||||
innerCodecs[contentType.subType] = contentType.charset;
|
||||
_defaultCharsetMap[contentType.primaryType] = innerCodecs;
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggles whether HTTP bodies of [contentType] are compressed with GZIP.
|
||||
///
|
||||
/// Use this method when wanting to compress a [Response.body], but there is no need for a [Codec] to transform
|
||||
/// the body object.
|
||||
void setAllowsCompression(ContentType contentType, bool allowed) {
|
||||
if (contentType.subType == "*") {
|
||||
_primaryTypeCompressionMap[contentType.primaryType] = allowed;
|
||||
} else {
|
||||
final innerCompress =
|
||||
_fullySpecifiedCompressionMap[contentType.primaryType] ?? {};
|
||||
innerCompress[contentType.subType] = allowed;
|
||||
_fullySpecifiedCompressionMap[contentType.primaryType] = innerCompress;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether or not [contentType] has been configured to be compressed.
|
||||
///
|
||||
/// See also [setAllowsCompression].
|
||||
bool isContentTypeCompressable(ContentType? contentType) {
|
||||
final subtypeCompress =
|
||||
_fullySpecifiedCompressionMap[contentType?.primaryType];
|
||||
if (subtypeCompress != null) {
|
||||
if (subtypeCompress.containsKey(contentType?.subType)) {
|
||||
return subtypeCompress[contentType?.subType] ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
return _primaryTypeCompressionMap[contentType?.primaryType] ?? false;
|
||||
}
|
||||
|
||||
/// Returns a [Codec] for [contentType].
|
||||
///
|
||||
/// See [add].
|
||||
Codec<dynamic, List<int>>? codecForContentType(ContentType? contentType) {
|
||||
if (contentType == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Codec? contentCodec;
|
||||
Codec<String, List<int>>? charsetCodec;
|
||||
|
||||
final subtypes = _fullySpecificedCodecs[contentType.primaryType];
|
||||
if (subtypes != null) {
|
||||
contentCodec = subtypes[contentType.subType];
|
||||
}
|
||||
|
||||
contentCodec ??= _primaryTypeCodecs[contentType.primaryType];
|
||||
|
||||
if ((contentType.charset?.length ?? 0) > 0) {
|
||||
charsetCodec = _codecForCharset(contentType.charset);
|
||||
} else if (contentType.primaryType == "text" && contentCodec == null) {
|
||||
charsetCodec = latin1;
|
||||
} else {
|
||||
charsetCodec = _defaultCharsetCodecForType(contentType);
|
||||
}
|
||||
|
||||
if (contentCodec != null) {
|
||||
if (charsetCodec != null) {
|
||||
return contentCodec.fuse(charsetCodec);
|
||||
}
|
||||
if (contentCodec is! Codec<dynamic, List<int>>) {
|
||||
throw StateError("Invalid codec selected. Does not emit 'List<int>'.");
|
||||
}
|
||||
return contentCodec;
|
||||
}
|
||||
|
||||
if (charsetCodec != null) {
|
||||
return charsetCodec;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Codec<String, List<int>> _codecForCharset(String? charset) {
|
||||
final encoding = Encoding.getByName(charset);
|
||||
if (encoding == null) {
|
||||
throw Response(415, null, {"error": "invalid charset '$charset'"});
|
||||
}
|
||||
|
||||
return encoding;
|
||||
}
|
||||
|
||||
Codec<String, List<int>>? _defaultCharsetCodecForType(ContentType type) {
|
||||
final inner = _defaultCharsetMap[type.primaryType];
|
||||
if (inner == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final encodingName = inner[type.subType] ?? inner["*"];
|
||||
if (encodingName == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Encoding.getByName(encodingName);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormCodec extends Codec<Map<String, dynamic>?, dynamic> {
|
||||
const _FormCodec();
|
||||
|
||||
@override
|
||||
Converter<Map<String, dynamic>, String> get encoder => const _FormEncoder();
|
||||
|
||||
@override
|
||||
Converter<String, Map<String, dynamic>> get decoder => const _FormDecoder();
|
||||
}
|
||||
|
||||
class _FormEncoder extends Converter<Map<String, dynamic>, String> {
|
||||
const _FormEncoder();
|
||||
|
||||
@override
|
||||
String convert(Map<String, dynamic> data) {
|
||||
return data.keys.map((k) => _encodePair(k, data[k])).join("&");
|
||||
}
|
||||
|
||||
String _encodePair(String key, dynamic value) {
|
||||
String encode(String v) => "$key=${Uri.encodeQueryComponent(v)}";
|
||||
if (value is List<String>) {
|
||||
return value.map(encode).join("&");
|
||||
} else if (value is String) {
|
||||
return encode(value);
|
||||
}
|
||||
|
||||
throw ArgumentError(
|
||||
"Cannot encode value '$value' for key '$key'. Must be 'String' or 'List<String>'",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormDecoder extends Converter<String, Map<String, dynamic>> {
|
||||
// This class may take input as either String or List<int>. If charset is not defined in request,
|
||||
// then data is List<int> (from CodecRegistry) and will default to being UTF8 decoded first.
|
||||
// Otherwise, if String, the request body has been decoded according to charset already.
|
||||
|
||||
const _FormDecoder();
|
||||
|
||||
@override
|
||||
Map<String, dynamic> convert(String data) {
|
||||
return Uri(query: data).queryParametersAll;
|
||||
}
|
||||
|
||||
@override
|
||||
_FormSink startChunkedConversion(Sink<Map<String, dynamic>> outSink) {
|
||||
return _FormSink(outSink);
|
||||
}
|
||||
}
|
||||
|
||||
class _FormSink implements ChunkedConversionSink<String> {
|
||||
_FormSink(this._outSink);
|
||||
|
||||
final _FormDecoder decoder = const _FormDecoder();
|
||||
final Sink<Map<String, dynamic>> _outSink;
|
||||
final StringBuffer _buffer = StringBuffer();
|
||||
|
||||
@override
|
||||
void add(String data) {
|
||||
_buffer.write(data);
|
||||
}
|
||||
|
||||
@override
|
||||
void close() {
|
||||
_outSink.add(decoder.convert(_buffer.toString()));
|
||||
_outSink.close();
|
||||
}
|
||||
}
|
513
packages/http/lib/src/managed_object_controller.dart
Normal file
513
packages/http/lib/src/managed_object_controller.dart
Normal file
|
@ -0,0 +1,513 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_database/db.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// A [Controller] that implements basic CRUD operations for a [ManagedObject].
|
||||
///
|
||||
/// Instances of this class map a REST API call
|
||||
/// directly to a database [Query]. For example, this [Controller] handles an HTTP PUT request by executing an update [Query]; the path variable in the request
|
||||
/// indicates the value of the primary key for the updated row and the HTTP request body are the values updated.
|
||||
///
|
||||
/// When routing to a [ManagedObjectController], you must provide the following route pattern, where <name> can be any string:
|
||||
///
|
||||
/// router.route("/<name>/[:id]")
|
||||
///
|
||||
/// You may optionally use the static method [ManagedObjectController.routePattern] to create this string for you.
|
||||
///
|
||||
/// The mapping for HTTP request to action is as follows:
|
||||
///
|
||||
/// - GET /<name>/:id -> Fetch Object by ID
|
||||
/// - PUT /<name>/:id -> Update Object by ID, HTTP Request Body contains update values.
|
||||
/// - DELETE /<name>/:id -> Delete Object by ID
|
||||
/// - POST /<name> -> Create new Object, HTTP Request Body contains update values.
|
||||
/// - GET /<name> -> Fetch instances of Object
|
||||
///
|
||||
/// You may use this class without subclassing, but you may also subclass it to modify the executed [Query] prior to its execution, or modify the returned [Response] after the query has been completed.
|
||||
///
|
||||
/// The HTTP response body is encoded according to [responseContentType].
|
||||
///
|
||||
/// GET requests with no path parameter can take extra query parameters to modify the request. The following are the available query parameters:
|
||||
///
|
||||
/// - count (integer): restricts the number of objects fetched to count. By default, this is null, which means no restrictions.
|
||||
/// - offset (integer): offsets the fetch by offset amount of objects. By default, this is null, which means no offset.
|
||||
/// - pageBy (string): indicates the key in which to page by. See [Query.pageBy] for more information on paging. If this value is passed as part of the query, either pageAfter or pagePrior must also be passed, but only one of those.
|
||||
/// - pageAfter (string): indicates the page value and direction of the paging. pageBy must also be set. See [Query.pageBy] for more information.
|
||||
/// - pagePrior (string): indicates the page value and direction of the paging. pageBy must also be set. See [Query.pageBy] for more information.
|
||||
/// - sortBy (string): indicates the sort order. The syntax is 'sortBy=key,order' where key is a property of [InstanceType] and order is either 'asc' or 'desc'. You may specify multiple sortBy parameters.
|
||||
class ManagedObjectController<InstanceType extends ManagedObject>
|
||||
extends ResourceController {
|
||||
/// Creates an instance of a [ManagedObjectController].
|
||||
ManagedObjectController(ManagedContext context) : super() {
|
||||
_query = Query<InstanceType>(context);
|
||||
}
|
||||
|
||||
/// Creates a new [ManagedObjectController] without a static type.
|
||||
///
|
||||
/// This method is used when generating instances of this type dynamically from runtime values,
|
||||
/// where the static type argument cannot be defined. Behaves just like the unnamed constructor.
|
||||
///
|
||||
ManagedObjectController.forEntity(
|
||||
ManagedEntity entity,
|
||||
ManagedContext context,
|
||||
) : super() {
|
||||
_query = Query.forEntity(entity, context);
|
||||
}
|
||||
|
||||
/// Returns a route pattern for using [ManagedObjectController]s.
|
||||
///
|
||||
/// Returns the string "/$name/[:id]", to be used as a route pattern in a [Router] for instances of [ResourceController] and subclasses.
|
||||
static String routePattern(String name) {
|
||||
return "/$name/[:id]";
|
||||
}
|
||||
|
||||
Query<InstanceType>? _query;
|
||||
|
||||
/// Executed prior to a fetch by ID query.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. The [query] will have a single matcher, where the [InstanceType]'s primary key
|
||||
/// is equal to the first path argument in the [Request]. You may also return a new [Query],
|
||||
/// but it must have the same [InstanceType] as this controller. If you return null from this method, no [Query] will be executed
|
||||
/// and [didNotFindObject] will immediately be called.
|
||||
FutureOr<Query<InstanceType>?> willFindObjectWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after a fetch by ID query that found a matching instance.
|
||||
///
|
||||
/// By default, returns a [Response.ok] with the encoded instance. The [result] is the fetched [InstanceType]. You may override this method
|
||||
/// to provide some other behavior.
|
||||
FutureOr<Response> didFindObject(InstanceType result) {
|
||||
return Response.ok(result);
|
||||
}
|
||||
|
||||
/// Executed after a fetch by ID query that did not find a matching instance.
|
||||
///
|
||||
/// By default, returns [Response.notFound]. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didNotFindObject() {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
@Operation.get("id")
|
||||
Future<Response> getObject(@Bind.path("id") String id) async {
|
||||
final primaryKey = _query!.entity.primaryKey;
|
||||
final parsedIdentifier =
|
||||
_getIdentifierFromPath(id, _query!.entity.properties[primaryKey]);
|
||||
_query!.where((o) => o[primaryKey]).equalTo(parsedIdentifier);
|
||||
|
||||
_query = await willFindObjectWithQuery(_query);
|
||||
|
||||
final InstanceType? result = await _query?.fetchOne();
|
||||
|
||||
if (result == null) {
|
||||
return didNotFindObject();
|
||||
} else {
|
||||
return didFindObject(result);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed prior to an insert query being executed.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. You may also return a new [Query],
|
||||
/// but it must have the same type argument as this controller. If you return null from this method,
|
||||
/// no values will be inserted and [didInsertObject] will immediately be called with the value null.
|
||||
FutureOr<Query<InstanceType>?> willInsertObjectWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after an insert query is successful.
|
||||
///
|
||||
/// By default, returns [Response.ok]. The [object] is the newly inserted [InstanceType]. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didInsertObject(InstanceType object) {
|
||||
return Response.ok(object);
|
||||
}
|
||||
|
||||
@Operation.post()
|
||||
Future<Response> createObject() async {
|
||||
final instance = _query!.entity.instanceOf() as InstanceType;
|
||||
instance.readFromMap(request!.body.as());
|
||||
_query!.values = instance;
|
||||
|
||||
_query = await willInsertObjectWithQuery(_query);
|
||||
final InstanceType result = (await _query?.insert())!;
|
||||
|
||||
return didInsertObject(result);
|
||||
}
|
||||
|
||||
/// Executed prior to a delete query being executed.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. You may also return a new [Query],
|
||||
/// but it must have the same type argument as this controller. If you return null from this method,
|
||||
/// no delete operation will be performed and [didNotFindObjectToDeleteWithID] will immediately be called with the value null.
|
||||
FutureOr<Query<InstanceType>?> willDeleteObjectWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after an object was deleted.
|
||||
///
|
||||
/// By default, returns [Response.ok] with no response body. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didDeleteObjectWithID(dynamic id) {
|
||||
return Response.ok(null);
|
||||
}
|
||||
|
||||
/// Executed when no object was deleted during a delete query.
|
||||
///
|
||||
/// Defaults to return [Response.notFound]. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didNotFindObjectToDeleteWithID(dynamic id) {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
@Operation.delete("id")
|
||||
Future<Response> deleteObject(@Bind.path("id") String id) async {
|
||||
final primaryKey = _query!.entity.primaryKey;
|
||||
final parsedIdentifier =
|
||||
_getIdentifierFromPath(id, _query!.entity.properties[primaryKey]);
|
||||
_query!.where((o) => o[primaryKey]).equalTo(parsedIdentifier);
|
||||
|
||||
_query = await willDeleteObjectWithQuery(_query);
|
||||
|
||||
final result = await _query?.delete();
|
||||
|
||||
if (result == 0) {
|
||||
return didNotFindObjectToDeleteWithID(id);
|
||||
} else {
|
||||
return didDeleteObjectWithID(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed prior to a update query being executed.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. You may also return a new [Query],
|
||||
/// but it must have the same type argument as this controller. If you return null from this method,
|
||||
/// no values will be inserted and [didNotFindObjectToUpdateWithID] will immediately be called with the value null.
|
||||
FutureOr<Query<InstanceType>?> willUpdateObjectWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after an object was updated.
|
||||
///
|
||||
/// By default, returns [Response.ok] with the encoded, updated object. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didUpdateObject(InstanceType object) {
|
||||
return Response.ok(object);
|
||||
}
|
||||
|
||||
/// Executed after an object not found during an update query.
|
||||
///
|
||||
/// By default, returns [Response.notFound]. You may override this method to provide some other behavior.
|
||||
FutureOr<Response> didNotFindObjectToUpdateWithID(dynamic id) {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
@Operation.put("id")
|
||||
Future<Response> updateObject(@Bind.path("id") String id) async {
|
||||
final primaryKey = _query!.entity.primaryKey;
|
||||
final parsedIdentifier =
|
||||
_getIdentifierFromPath(id, _query!.entity.properties[primaryKey]);
|
||||
_query!.where((o) => o[primaryKey]).equalTo(parsedIdentifier);
|
||||
|
||||
final instance = _query!.entity.instanceOf() as InstanceType;
|
||||
instance.readFromMap(request!.body.as());
|
||||
_query!.values = instance;
|
||||
|
||||
_query = await willUpdateObjectWithQuery(_query);
|
||||
|
||||
final InstanceType? results = await _query?.updateOne();
|
||||
if (results == null) {
|
||||
return didNotFindObjectToUpdateWithID(id);
|
||||
} else {
|
||||
return didUpdateObject(results);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executed prior to a fetch query being executed.
|
||||
///
|
||||
/// You may modify the [query] prior to its execution in this method. You may also return a new [Query],
|
||||
/// but it must have the same type argument as this controller. If you return null from this method,
|
||||
/// no objects will be fetched and [didFindObjects] will immediately be called with the value null.
|
||||
FutureOr<Query<InstanceType>?> willFindObjectsWithQuery(
|
||||
Query<InstanceType>? query,
|
||||
) {
|
||||
return query;
|
||||
}
|
||||
|
||||
/// Executed after a list of objects has been fetched.
|
||||
///
|
||||
/// By default, returns [Response.ok] with the encoded list of founds objects (which may be the empty list).
|
||||
FutureOr<Response> didFindObjects(List<InstanceType> objects) {
|
||||
return Response.ok(objects);
|
||||
}
|
||||
|
||||
@Operation.get()
|
||||
Future<Response> getObjects({
|
||||
/// Limits the number of objects returned.
|
||||
@Bind.query("count") int count = 0,
|
||||
|
||||
/// An integer offset into an ordered list of objects.
|
||||
///
|
||||
/// Use with count.
|
||||
///
|
||||
/// See pageBy for an alternative form of offsetting.
|
||||
@Bind.query("offset") int offset = 0,
|
||||
|
||||
/// The property of this object to page by.
|
||||
///
|
||||
/// Must be a key in the object type being fetched. Must
|
||||
/// provide either pageAfter or pagePrior. Use with count.
|
||||
@Bind.query("pageBy") String? pageBy,
|
||||
|
||||
/// A value-based offset into an ordered list of objects.
|
||||
///
|
||||
/// Objects are returned if their
|
||||
/// value for the property named by pageBy is greater than
|
||||
/// the value of pageAfter. Must provide pageBy, and the type
|
||||
/// of the property designated by pageBy must be the same as pageAfter.
|
||||
@Bind.query("pageAfter") String? pageAfter,
|
||||
|
||||
/// A value-based offset into an ordered list of objects.
|
||||
///
|
||||
/// Objects are returned if their
|
||||
/// value for the property named by pageBy is less than
|
||||
/// the value of pageAfter. Must provide pageBy, and the type
|
||||
/// of the property designated by pageBy must be the same as pageAfter.
|
||||
@Bind.query("pagePrior") String? pagePrior,
|
||||
|
||||
/// Designates a sorting strategy for the returned objects.
|
||||
///
|
||||
/// This value must take the form 'name,asc' or 'name,desc', where name
|
||||
/// is the property of the returned objects to sort on.
|
||||
@Bind.query("sortBy") List<String>? sortBy,
|
||||
}) async {
|
||||
_query!.fetchLimit = count;
|
||||
_query!.offset = offset;
|
||||
|
||||
if (pageBy != null) {
|
||||
QuerySortOrder direction;
|
||||
String pageValue;
|
||||
if (pageAfter != null) {
|
||||
direction = QuerySortOrder.ascending;
|
||||
pageValue = pageAfter;
|
||||
} else if (pagePrior != null) {
|
||||
direction = QuerySortOrder.descending;
|
||||
pageValue = pagePrior;
|
||||
} else {
|
||||
return Response.badRequest(
|
||||
body: {
|
||||
"error":
|
||||
"missing required parameter 'pageAfter' or 'pagePrior' when 'pageBy' is given"
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final pageByProperty = _query!.entity.properties[pageBy];
|
||||
if (pageByProperty == null) {
|
||||
throw Response.badRequest(body: {"error": "cannot page by '$pageBy'"});
|
||||
}
|
||||
|
||||
final parsed = _parseValueForProperty(pageValue, pageByProperty);
|
||||
_query!.pageBy(
|
||||
(t) => t[pageBy],
|
||||
direction,
|
||||
boundingValue: parsed == "null" ? null : parsed,
|
||||
);
|
||||
}
|
||||
|
||||
if (sortBy != null) {
|
||||
for (final sort in sortBy) {
|
||||
final split = sort.split(",").map((str) => str.trim()).toList();
|
||||
if (split.length != 2) {
|
||||
throw Response.badRequest(
|
||||
body: {
|
||||
"error":
|
||||
"invalid 'sortyBy' format. syntax: 'name,asc' or 'name,desc'."
|
||||
},
|
||||
);
|
||||
}
|
||||
if (_query!.entity.properties[split.first] == null) {
|
||||
throw Response.badRequest(
|
||||
body: {"error": "cannot sort by '$sortBy'"},
|
||||
);
|
||||
}
|
||||
if (split.last != "asc" && split.last != "desc") {
|
||||
throw Response.badRequest(
|
||||
body: {
|
||||
"error":
|
||||
"invalid 'sortBy' format. syntax: 'name,asc' or 'name,desc'."
|
||||
},
|
||||
);
|
||||
}
|
||||
final sortOrder = split.last == "asc"
|
||||
? QuerySortOrder.ascending
|
||||
: QuerySortOrder.descending;
|
||||
_query!.sortBy((t) => t[split.first], sortOrder);
|
||||
}
|
||||
}
|
||||
|
||||
_query = await willFindObjectsWithQuery(_query);
|
||||
|
||||
final results = (await _query?.fetch())!;
|
||||
|
||||
return didFindObjects(results);
|
||||
}
|
||||
|
||||
@override
|
||||
APIRequestBody? documentOperationRequestBody(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
if (operation!.method == "POST" || operation.method == "PUT") {
|
||||
return APIRequestBody.schema(
|
||||
context.schema.getObjectWithType(InstanceType),
|
||||
contentTypes: ["application/json"],
|
||||
isRequired: true,
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIResponse> documentOperationResponses(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
switch (operation!.method) {
|
||||
case "GET":
|
||||
if (operation.pathVariables.isEmpty) {
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Returns a list of objects.",
|
||||
APISchemaObject.array(
|
||||
ofSchema: context.schema.getObjectWithType(InstanceType),
|
||||
),
|
||||
),
|
||||
"400": APIResponse.schema(
|
||||
"Invalid request.",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Returns a single object.",
|
||||
context.schema.getObjectWithType(InstanceType),
|
||||
),
|
||||
"404": APIResponse("No object found.")
|
||||
};
|
||||
case "PUT":
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Returns updated object.",
|
||||
context.schema.getObjectWithType(InstanceType),
|
||||
),
|
||||
"404": APIResponse("No object found."),
|
||||
"400": APIResponse.schema(
|
||||
"Invalid request.",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
),
|
||||
"409": APIResponse.schema(
|
||||
"Object already exists",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
),
|
||||
};
|
||||
case "POST":
|
||||
return {
|
||||
"200": APIResponse.schema(
|
||||
"Returns created object.",
|
||||
context.schema.getObjectWithType(InstanceType),
|
||||
),
|
||||
"400": APIResponse.schema(
|
||||
"Invalid request.",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
),
|
||||
"409": APIResponse.schema(
|
||||
"Object already exists",
|
||||
APISchemaObject.object({"error": APISchemaObject.string()}),
|
||||
)
|
||||
};
|
||||
case "DELETE":
|
||||
return {
|
||||
"200": APIResponse("Object successfully deleted."),
|
||||
"404": APIResponse("No object found."),
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
final ops = super.documentOperations(context, route, path);
|
||||
|
||||
final entityName = _query!.entity.name;
|
||||
|
||||
if (path.parameters
|
||||
.where((p) => p!.location == APIParameterLocation.path)
|
||||
.isNotEmpty) {
|
||||
ops["get"]!.id = "get$entityName";
|
||||
ops["put"]!.id = "update$entityName";
|
||||
ops["delete"]!.id = "delete$entityName";
|
||||
} else {
|
||||
ops["get"]!.id = "get${entityName}s";
|
||||
ops["post"]!.id = "create$entityName";
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
dynamic _getIdentifierFromPath(
|
||||
String value,
|
||||
ManagedPropertyDescription? desc,
|
||||
) {
|
||||
return _parseValueForProperty(value, desc, onError: Response.notFound());
|
||||
}
|
||||
|
||||
dynamic _parseValueForProperty(
|
||||
String value,
|
||||
ManagedPropertyDescription? desc, {
|
||||
Response? onError,
|
||||
}) {
|
||||
if (value == "null") {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (desc!.type!.kind) {
|
||||
case ManagedPropertyType.string:
|
||||
return value;
|
||||
case ManagedPropertyType.bigInteger:
|
||||
return int.parse(value);
|
||||
case ManagedPropertyType.integer:
|
||||
return int.parse(value);
|
||||
case ManagedPropertyType.datetime:
|
||||
return DateTime.parse(value);
|
||||
case ManagedPropertyType.doublePrecision:
|
||||
return double.parse(value);
|
||||
case ManagedPropertyType.boolean:
|
||||
return value == "true";
|
||||
case ManagedPropertyType.list:
|
||||
return null;
|
||||
case ManagedPropertyType.map:
|
||||
return null;
|
||||
case ManagedPropertyType.document:
|
||||
return null;
|
||||
}
|
||||
} on FormatException {
|
||||
throw onError ?? Response.badRequest();
|
||||
}
|
||||
}
|
||||
}
|
72
packages/http/lib/src/query_controller.dart
Normal file
72
packages/http/lib/src/query_controller.dart
Normal file
|
@ -0,0 +1,72 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:protevus_database/db.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// A partial class for implementing an [ResourceController] that has a few conveniences
|
||||
/// for executing [Query]s.
|
||||
///
|
||||
/// Instances of [QueryController] are [ResourceController]s that have a pre-baked [Query] available. This [Query]'s type -
|
||||
/// the [ManagedObject] type is operates on - is defined by [InstanceType].
|
||||
///
|
||||
/// The values of [query] are set based on the HTTP method, HTTP path and request body.
|
||||
/// Prior to executing an operation method in subclasses of [QueryController], the [query]
|
||||
/// will have the following attributes under the following conditions:
|
||||
///
|
||||
/// 1. The [Query] will always have a type argument that matches [InstanceType].
|
||||
/// 2. If the request contains a path variable that matches the name of the primary key of [InstanceType], the [Query] will set
|
||||
/// its [Query.where] to match on the [ManagedObject] whose primary key is that value of the path parameter.
|
||||
/// 3. If the [Request] contains a body, it will be decoded per the [acceptedContentTypes] and deserialized into the [Query.values] property via [ManagedObject.readFromMap].
|
||||
abstract class QueryController<InstanceType extends ManagedObject>
|
||||
extends ResourceController {
|
||||
/// Create an instance of [QueryController].
|
||||
QueryController(ManagedContext context) : super() {
|
||||
query = Query<InstanceType>(context);
|
||||
}
|
||||
|
||||
/// A query representing the values received from the [request] being processed.
|
||||
///
|
||||
/// You may execute this [query] as is or modify it. The following is true of this property:
|
||||
///
|
||||
/// 1. The [Query] will always have a type argument that matches [InstanceType].
|
||||
/// 2. If the request contains a path variable that matches the name of the primary key of [InstanceType], the [Query] will set
|
||||
/// its [Query.where] to match on the [ManagedObject] whose primary key is that value of the path parameter.
|
||||
/// 3. If the [Request] contains a body, it will be decoded per the [acceptedContentTypes] and deserialized into the [Query.values] property via [ManagedObject.readFromMap].
|
||||
Query<InstanceType>? query;
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> willProcessRequest(Request req) {
|
||||
if (req.path.orderedVariableNames.isNotEmpty) {
|
||||
final firstVarName = req.path.orderedVariableNames.first;
|
||||
final idValue = req.path.variables[firstVarName];
|
||||
|
||||
if (idValue != null) {
|
||||
final primaryKeyDesc =
|
||||
query!.entity.attributes[query!.entity.primaryKey]!;
|
||||
if (primaryKeyDesc.isAssignableWith(idValue)) {
|
||||
query!.where((o) => o[query!.entity.primaryKey]).equalTo(idValue);
|
||||
} else if (primaryKeyDesc.type!.kind ==
|
||||
ManagedPropertyType.bigInteger ||
|
||||
primaryKeyDesc.type!.kind == ManagedPropertyType.integer) {
|
||||
try {
|
||||
query!
|
||||
.where((o) => o[query!.entity.primaryKey])
|
||||
.equalTo(int.parse(idValue));
|
||||
} on FormatException {
|
||||
return Response.notFound();
|
||||
}
|
||||
} else {
|
||||
return Response.notFound();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return super.willProcessRequest(req);
|
||||
}
|
||||
|
||||
@override
|
||||
void didDecodeRequestBody(RequestBody body) {
|
||||
query!.values.readFromMap(body.as());
|
||||
query!.values.removePropertyFromBackingMap(query!.values.entity.primaryKey);
|
||||
}
|
||||
}
|
448
packages/http/lib/src/request.dart
Normal file
448
packages/http/lib/src/request.dart
Normal file
|
@ -0,0 +1,448 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// A single HTTP request.
|
||||
///
|
||||
/// Instances of this class travel through a [Controller] chain to be responded to, sometimes acquiring values
|
||||
/// as they go through controllers. Each instance of this class has a standard library [HttpRequest]. You should not respond
|
||||
/// directly to the [HttpRequest], as [Controller]s take that responsibility.
|
||||
class Request implements RequestOrResponse {
|
||||
/// Creates an instance of [Request], no need to do so manually.
|
||||
Request(this.raw)
|
||||
: path = RequestPath(raw.uri.pathSegments),
|
||||
body = RequestBody(raw);
|
||||
|
||||
/// The underlying [HttpRequest] of this instance.
|
||||
///
|
||||
/// Use this property to access values from the HTTP request that aren't accessible through this instance.
|
||||
///
|
||||
/// You should typically not manipulate this property's [HttpRequest.response]. By default, Conduit controls
|
||||
/// the response through its [Controller]s.
|
||||
///
|
||||
/// If you wish to respond to a request manually - and prohibit Conduit from responding to the request - you must
|
||||
/// remove this instance from the request channel. To remove a request from the channel, return null from a [Controller]
|
||||
/// handler method instead of a [Response] or [Request]. For example:
|
||||
///
|
||||
/// router.route("/raw").linkFunction((req) async {
|
||||
/// req.response.statusCode = 200;
|
||||
/// await req.response.close(); // Respond manually to request
|
||||
/// return null; // Take request out of channel; no subsequent controllers will see this request.
|
||||
/// });
|
||||
final HttpRequest raw;
|
||||
|
||||
/// HTTP method of this request.
|
||||
///
|
||||
/// Always uppercase. e.g., GET, POST, PUT.
|
||||
String get method => raw.method.toUpperCase();
|
||||
|
||||
/// The path of the request URI.
|
||||
///
|
||||
/// Provides convenient access to the request URI path. Also provides path variables and wildcard path values
|
||||
/// after this instance is handled by a [Router].
|
||||
final RequestPath path;
|
||||
|
||||
/// The request body object.
|
||||
///
|
||||
/// This object contains the request body if one exists and behavior for decoding it according
|
||||
/// to this instance's content-type. See [RequestBody] for details on decoding the body into
|
||||
/// an object (or objects).
|
||||
///
|
||||
/// This value is is always non-null. If there is no request body, [RequestBody.isEmpty] is true.
|
||||
final RequestBody body;
|
||||
|
||||
/// Information about the client connection.
|
||||
///
|
||||
/// Note: accessing this property incurs a significant performance penalty.
|
||||
HttpConnectionInfo? get connectionInfo => raw.connectionInfo;
|
||||
|
||||
/// The response object of this [Request].
|
||||
///
|
||||
/// Do not write to this value manually. [Controller]s are responsible for
|
||||
/// using a [Response] instance to fill out this property.
|
||||
HttpResponse get response => raw.response;
|
||||
|
||||
/// Authorization information associated with this request.
|
||||
///
|
||||
/// When this request goes through an [Authorizer], this value will be set with
|
||||
/// permission information from the authenticator. Use this to determine client, resource owner
|
||||
/// or other properties of the authentication information in the request. This value will be
|
||||
/// null if no permission has been set.
|
||||
Authorization? authorization;
|
||||
|
||||
List<void Function(Response)>? _responseModifiers;
|
||||
|
||||
/// The acceptable content types for a [Response] returned for this instance.
|
||||
///
|
||||
/// This list is determined by parsing the `Accept` header (or the concatenation
|
||||
/// of multiple `Accept` headers). The list is ordered such the more desirable
|
||||
/// content-types appear earlier in the list. Desirability is determined by
|
||||
/// a q-value (if one exists) and the specificity of the content-type.
|
||||
///
|
||||
/// See also [acceptsContentType].
|
||||
List<ContentType> get acceptableContentTypes {
|
||||
if (_cachedAcceptableTypes == null) {
|
||||
try {
|
||||
final contentTypes = raw.headers[HttpHeaders.acceptHeader]
|
||||
?.expand((h) => h.split(",").map((s) => s.trim()))
|
||||
.where((h) => h.isNotEmpty)
|
||||
.map(ContentType.parse)
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
contentTypes.sort((c1, c2) {
|
||||
final num q1 = num.parse(c1.parameters["q"] ?? "1.0");
|
||||
final q2 = num.parse(c2.parameters["q"] ?? "1.0");
|
||||
|
||||
final comparison = q1.compareTo(q2);
|
||||
if (comparison == 0) {
|
||||
if (c1.primaryType == "*" && c2.primaryType != "*") {
|
||||
return 1;
|
||||
} else if (c1.primaryType != "*" && c2.primaryType == "*") {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (c1.subType == "*" && c2.subType != "*") {
|
||||
return 1;
|
||||
} else if (c1.subType != "*" && c2.subType == "*") {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
return -comparison;
|
||||
});
|
||||
|
||||
_cachedAcceptableTypes = contentTypes;
|
||||
} catch (_) {
|
||||
throw Response.badRequest(
|
||||
body: {"error": "accept header is malformed"},
|
||||
);
|
||||
}
|
||||
}
|
||||
return _cachedAcceptableTypes!;
|
||||
}
|
||||
|
||||
List<ContentType>? _cachedAcceptableTypes;
|
||||
|
||||
/// Whether a [Response] may contain a body of type [contentType].
|
||||
///
|
||||
/// This method searches [acceptableContentTypes] for a match with [contentType]. If one exists,
|
||||
/// this method returns true. Otherwise, it returns false.
|
||||
///
|
||||
/// Note that if no Accept header is present, this method always returns true.
|
||||
bool acceptsContentType(ContentType contentType) {
|
||||
if (acceptableContentTypes.isEmpty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return acceptableContentTypes.any((acceptable) {
|
||||
if (acceptable.primaryType == "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (acceptable.primaryType == contentType.primaryType) {
|
||||
if (acceptable.subType == "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (acceptable.subType == contentType.subType) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether or not this request is a CORS request.
|
||||
///
|
||||
/// This is true if there is an Origin header.
|
||||
bool get isCORSRequest => raw.headers.value("origin") != null;
|
||||
|
||||
/// Whether or not this is a CORS preflight request.
|
||||
///
|
||||
/// This is true if the request HTTP method is OPTIONS and the headers contains Access-Control-Request-Method.
|
||||
bool get isPreflightRequest {
|
||||
return isCORSRequest &&
|
||||
raw.method == "OPTIONS" &&
|
||||
raw.headers.value("access-control-request-method") != null;
|
||||
}
|
||||
|
||||
/// Container for any data a [Controller] wants to attach to this request for the purpose of being used by a later [Controller].
|
||||
///
|
||||
/// Use this property to attach data to a [Request] for use by later [Controller]s.
|
||||
Map<dynamic, dynamic> attachments = {};
|
||||
|
||||
/// The timestamp for when this request was received.
|
||||
DateTime receivedDate = DateTime.now().toUtc();
|
||||
|
||||
/// The timestamp for when this request was responded to.
|
||||
///
|
||||
/// Used for logging.
|
||||
DateTime? respondDate;
|
||||
|
||||
/// Allows a [Controller] to modify the response eventually created for this request, without creating that response itself.
|
||||
///
|
||||
/// Executes [modifier] prior to sending the HTTP response for this request. Modifiers are executed in the order they were added and may contain
|
||||
/// modifiers from other [Controller]s. Modifiers are executed prior to any data encoded or is written to the network socket.
|
||||
///
|
||||
/// This is valuable for middleware that wants to include some information in the response, but some other controller later in the channel
|
||||
/// will create the response. [modifier] will run prior to
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// Future<RequestOrResponse> handle(Request request) async {
|
||||
/// request.addResponseModifier((r) {
|
||||
/// r.headers["x-rate-limit-remaining"] = 200;
|
||||
/// });
|
||||
/// return request;
|
||||
/// }
|
||||
void addResponseModifier(void Function(Response response) modifier) {
|
||||
_responseModifiers ??= [];
|
||||
_responseModifiers!.add(modifier);
|
||||
}
|
||||
|
||||
String get _sanitizedHeaders {
|
||||
final StringBuffer buf = StringBuffer("{");
|
||||
|
||||
raw.headers.forEach((k, v) {
|
||||
buf.write("${_truncatedString(k)} : ${_truncatedString(v.join(","))}\\n");
|
||||
});
|
||||
buf.write("}");
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
String _truncatedString(String originalString, {int charSize = 128}) {
|
||||
if (originalString.length <= charSize) {
|
||||
return originalString;
|
||||
}
|
||||
return "${originalString.substring(0, charSize)} ... (${originalString.length - charSize} truncated bytes)";
|
||||
}
|
||||
|
||||
/// Sends a [Response] to this [Request]'s client.
|
||||
///
|
||||
/// Do not invoke this method directly.
|
||||
///
|
||||
/// [Controller]s invoke this method to respond to this request.
|
||||
///
|
||||
/// Once this method has executed, the [Request] is no longer valid. All headers from [conduitResponse] are
|
||||
/// added to the HTTP response. If [conduitResponse] has a [Response.body], this request will attempt to encode the body data according to the
|
||||
/// Content-Type in the [conduitResponse]'s [Response.headers].
|
||||
///
|
||||
Future respond(Response conduitResponse) {
|
||||
respondDate = DateTime.now().toUtc();
|
||||
|
||||
final modifiers = _responseModifiers;
|
||||
_responseModifiers = null;
|
||||
modifiers?.forEach((modifier) {
|
||||
modifier(conduitResponse);
|
||||
});
|
||||
|
||||
final _Reference<String> compressionType = _Reference(null);
|
||||
var body = conduitResponse.body;
|
||||
if (body is! Stream) {
|
||||
// Note: this pre-encodes the body in memory, such that encoding fails this will throw and we can return a 500
|
||||
// because we have yet to write to the response.
|
||||
body = _responseBodyBytes(conduitResponse, compressionType);
|
||||
}
|
||||
|
||||
response.statusCode = conduitResponse.statusCode!;
|
||||
conduitResponse.headers.forEach((k, v) {
|
||||
response.headers.add(k, v as Object);
|
||||
});
|
||||
|
||||
if (conduitResponse.cachePolicy != null) {
|
||||
response.headers.add(
|
||||
HttpHeaders.cacheControlHeader,
|
||||
conduitResponse.cachePolicy!.headerValue,
|
||||
);
|
||||
}
|
||||
|
||||
if (body == null) {
|
||||
response.headers.removeAll(HttpHeaders.contentTypeHeader);
|
||||
return response.close();
|
||||
}
|
||||
|
||||
response.headers.add(
|
||||
HttpHeaders.contentTypeHeader,
|
||||
conduitResponse.contentType.toString(),
|
||||
);
|
||||
|
||||
if (body is List<int>) {
|
||||
if (compressionType.value != null) {
|
||||
response.headers
|
||||
.add(HttpHeaders.contentEncodingHeader, compressionType.value!);
|
||||
}
|
||||
response.headers.add(HttpHeaders.contentLengthHeader, body.length);
|
||||
|
||||
response.add(body);
|
||||
|
||||
return response.close();
|
||||
} else if (body is Stream) {
|
||||
// Otherwise, body is stream
|
||||
final bodyStream = _responseBodyStream(conduitResponse, compressionType);
|
||||
if (compressionType.value != null) {
|
||||
response.headers
|
||||
.add(HttpHeaders.contentEncodingHeader, compressionType.value!);
|
||||
}
|
||||
response.headers.add(HttpHeaders.transferEncodingHeader, "chunked");
|
||||
response.bufferOutput = conduitResponse.bufferOutput;
|
||||
|
||||
return response.addStream(bodyStream).then((_) {
|
||||
return response.close();
|
||||
}).catchError((e, StackTrace st) {
|
||||
throw HTTPStreamingException(e, st);
|
||||
});
|
||||
}
|
||||
|
||||
throw StateError("Invalid response body. Could not encode.");
|
||||
}
|
||||
|
||||
List<int>? _responseBodyBytes(
|
||||
Response resp,
|
||||
_Reference<String> compressionType,
|
||||
) {
|
||||
if (resp.body == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Codec<dynamic, List<int>>? codec;
|
||||
if (resp.encodeBody) {
|
||||
codec =
|
||||
CodecRegistry.defaultInstance.codecForContentType(resp.contentType);
|
||||
}
|
||||
|
||||
// todo(joeconwaystk): Set minimum threshold on number of bytes needed to perform gzip, do not gzip otherwise.
|
||||
// There isn't a great way of doing this that I can think of except splitting out gzip from the fused codec,
|
||||
// have to measure the value of fusing vs the cost of gzipping smaller data.
|
||||
final canGzip = CodecRegistry.defaultInstance
|
||||
.isContentTypeCompressable(resp.contentType) &&
|
||||
_acceptsGzipResponseBody;
|
||||
|
||||
if (codec == null) {
|
||||
if (resp.body is! List<int>) {
|
||||
throw StateError(
|
||||
"Invalid response body. Body of type '${resp.body.runtimeType}' cannot be encoded as content-type '${resp.contentType}'.",
|
||||
);
|
||||
}
|
||||
|
||||
final bytes = resp.body as List<int>;
|
||||
if (canGzip) {
|
||||
compressionType.value = "gzip";
|
||||
return gzip.encode(bytes);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
if (canGzip) {
|
||||
compressionType.value = "gzip";
|
||||
codec = codec.fuse(gzip);
|
||||
}
|
||||
|
||||
return codec.encode(resp.body);
|
||||
}
|
||||
|
||||
Stream<List<int>> _responseBodyStream(
|
||||
Response resp,
|
||||
_Reference<String> compressionType,
|
||||
) {
|
||||
Codec<dynamic, List<int>>? codec;
|
||||
if (resp.encodeBody) {
|
||||
codec =
|
||||
CodecRegistry.defaultInstance.codecForContentType(resp.contentType);
|
||||
}
|
||||
|
||||
final canGzip = CodecRegistry.defaultInstance
|
||||
.isContentTypeCompressable(resp.contentType) &&
|
||||
_acceptsGzipResponseBody;
|
||||
if (codec == null) {
|
||||
if (resp.body is! Stream<List<int>>) {
|
||||
throw StateError(
|
||||
"Invalid response body. Body of type '${resp.body.runtimeType}' cannot be encoded as content-type '${resp.contentType}'.",
|
||||
);
|
||||
}
|
||||
|
||||
final stream = resp.body as Stream<List<int>>;
|
||||
if (canGzip) {
|
||||
compressionType.value = "gzip";
|
||||
return gzip.encoder.bind(stream);
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
if (canGzip) {
|
||||
compressionType.value = "gzip";
|
||||
codec = codec.fuse(gzip);
|
||||
}
|
||||
|
||||
return codec.encoder.bind(resp.body as Stream);
|
||||
}
|
||||
|
||||
bool get _acceptsGzipResponseBody {
|
||||
return raw.headers[HttpHeaders.acceptEncodingHeader]
|
||||
?.any((v) => v.split(",").any((s) => s.trim() == "gzip")) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "${raw.method} ${raw.uri} (${receivedDate.millisecondsSinceEpoch})";
|
||||
}
|
||||
|
||||
/// A string that represents more details about the request, typically used for logging.
|
||||
///
|
||||
/// Note: Setting includeRequestIP to true creates a significant performance penalty.
|
||||
String toDebugString({
|
||||
bool includeElapsedTime = true,
|
||||
bool includeRequestIP = false,
|
||||
bool includeMethod = true,
|
||||
bool includeResource = true,
|
||||
bool includeStatusCode = true,
|
||||
bool includeContentSize = false,
|
||||
bool includeHeaders = false,
|
||||
}) {
|
||||
final builder = StringBuffer();
|
||||
if (includeRequestIP) {
|
||||
builder.write("${raw.connectionInfo?.remoteAddress.address} ");
|
||||
}
|
||||
if (includeMethod) {
|
||||
builder.write("${raw.method} ");
|
||||
}
|
||||
if (includeResource) {
|
||||
builder.write("${raw.uri} ");
|
||||
}
|
||||
if (includeElapsedTime && respondDate != null) {
|
||||
builder
|
||||
.write("${respondDate!.difference(receivedDate).inMilliseconds}ms ");
|
||||
}
|
||||
if (includeStatusCode) {
|
||||
builder.write("${raw.response.statusCode} ");
|
||||
}
|
||||
if (includeContentSize) {
|
||||
builder.write("${raw.response.contentLength} ");
|
||||
}
|
||||
if (includeHeaders) {
|
||||
builder.write("$_sanitizedHeaders ");
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class HTTPStreamingException implements Exception {
|
||||
HTTPStreamingException(this.underlyingException, this.trace);
|
||||
|
||||
dynamic underlyingException;
|
||||
StackTrace trace;
|
||||
}
|
||||
|
||||
class _Reference<T> {
|
||||
_Reference(this.value);
|
||||
|
||||
T? value;
|
||||
}
|
105
packages/http/lib/src/request_body.dart
Normal file
105
packages/http/lib/src/request_body.dart
Normal file
|
@ -0,0 +1,105 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Objects that represent a request body, and can be decoded into Dart objects.
|
||||
///
|
||||
/// Every instance of [Request] has a [Request.body] property of this type. Use
|
||||
/// [decode] to convert the contents of this object into a Dart type (e.g, [Map] or [List]).
|
||||
///
|
||||
/// See also [CodecRegistry] for how decoding occurs.
|
||||
class RequestBody extends BodyDecoder {
|
||||
/// Creates a new instance of this type.
|
||||
///
|
||||
/// Instances of this type decode [request]'s body based on its content-type.
|
||||
///
|
||||
/// See [CodecRegistry] for more information about how data is decoded.
|
||||
///
|
||||
/// Decoded data is cached the after it is decoded.
|
||||
RequestBody(HttpRequest super.request)
|
||||
: _request = request,
|
||||
_originalByteStream = request;
|
||||
|
||||
/// The maximum size of a request body.
|
||||
///
|
||||
/// A request with a body larger than this size will be rejected. Value is in bytes. Defaults to 10MB (1024 * 1024 * 10).
|
||||
static int maxSize = 1024 * 1024 * 10;
|
||||
|
||||
final HttpRequest _request;
|
||||
|
||||
bool get _hasContent =>
|
||||
_hasContentLength || _request.headers.chunkedTransferEncoding;
|
||||
|
||||
bool get _hasContentLength => (_request.headers.contentLength) > 0;
|
||||
|
||||
@override
|
||||
Stream<List<int>> get bytes {
|
||||
// If content-length is specified, then we can check it for maxSize
|
||||
// and just return the original stream.
|
||||
if (_hasContentLength) {
|
||||
if (_request.headers.contentLength > maxSize) {
|
||||
throw Response(
|
||||
HttpStatus.requestEntityTooLarge,
|
||||
null,
|
||||
{"error": "entity length exceeds maximum"},
|
||||
);
|
||||
}
|
||||
|
||||
return _originalByteStream;
|
||||
}
|
||||
|
||||
// If content-length is not specified (e.g., chunked),
|
||||
// then we need to check how many bytes we've read to ensure we haven't
|
||||
// crossed maxSize
|
||||
if (_bufferingController == null) {
|
||||
_bufferingController = StreamController<List<int>>(sync: true);
|
||||
|
||||
_originalByteStream.listen(
|
||||
(chunk) {
|
||||
_bytesRead += chunk.length;
|
||||
if (_bytesRead > maxSize) {
|
||||
_bufferingController!.addError(
|
||||
Response(
|
||||
HttpStatus.requestEntityTooLarge,
|
||||
null,
|
||||
{"error": "entity length exceeds maximum"},
|
||||
),
|
||||
);
|
||||
_bufferingController!.close();
|
||||
return;
|
||||
}
|
||||
|
||||
_bufferingController!.add(chunk);
|
||||
},
|
||||
onDone: () {
|
||||
_bufferingController!.close();
|
||||
},
|
||||
onError: (Object e, StackTrace st) {
|
||||
if (!_bufferingController!.isClosed) {
|
||||
_bufferingController!.addError(e, st);
|
||||
_bufferingController!.close();
|
||||
}
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
}
|
||||
|
||||
return _bufferingController!.stream;
|
||||
}
|
||||
|
||||
@override
|
||||
ContentType? get contentType => _request.headers.contentType;
|
||||
|
||||
@override
|
||||
bool get isEmpty => !_hasContent;
|
||||
|
||||
bool get isFormData =>
|
||||
contentType != null &&
|
||||
contentType!.primaryType == "application" &&
|
||||
contentType!.subType == "x-www-form-urlencoded";
|
||||
|
||||
final Stream<List<int>> _originalByteStream;
|
||||
StreamController<List<int>>? _bufferingController;
|
||||
int _bytesRead = 0;
|
||||
}
|
82
packages/http/lib/src/request_path.dart
Normal file
82
packages/http/lib/src/request_path.dart
Normal file
|
@ -0,0 +1,82 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Stores path info for a [Request].
|
||||
///
|
||||
/// Contains the raw path string, the path as segments and values created by routing a request.
|
||||
///
|
||||
/// Note: The properties [variables], [orderedVariableNames] and [remainingPath] are not set until
|
||||
/// after the owning request has passed through a [Router].
|
||||
class RequestPath {
|
||||
/// Default constructor for [RequestPath].
|
||||
///
|
||||
/// There is no need to invoke this constructor manually.
|
||||
RequestPath(this.segments);
|
||||
|
||||
void setSpecification(RouteSpecification spec, {int segmentOffset = 0}) {
|
||||
final requestIterator = segments.iterator;
|
||||
for (var i = 0; i < segmentOffset; i++) {
|
||||
requestIterator.moveNext();
|
||||
}
|
||||
|
||||
for (final segment in spec.segments) {
|
||||
if (!requestIterator.moveNext()) {
|
||||
remainingPath = "";
|
||||
return;
|
||||
}
|
||||
final requestSegment = requestIterator.current;
|
||||
|
||||
if (segment.isVariable) {
|
||||
variables[segment.variableName.toString()] = requestSegment;
|
||||
orderedVariableNames.add(segment.variableName!);
|
||||
} else if (segment.isRemainingMatcher) {
|
||||
final remaining = [];
|
||||
remaining.add(requestIterator.current);
|
||||
while (requestIterator.moveNext()) {
|
||||
remaining.add(requestIterator.current);
|
||||
}
|
||||
remainingPath = remaining.join("/");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Map] of path variables.
|
||||
///
|
||||
/// If a path has variables (indicated by the :variable syntax),
|
||||
/// the matching segments for the path variables will be stored in the map. The key
|
||||
/// will be the variable name (without the colon) and the value will be the
|
||||
/// path segment as a string.
|
||||
///
|
||||
/// Consider a match specification /users/:id. If the evaluated path is
|
||||
/// /users/2
|
||||
/// This property will be {'id' : '2'}.
|
||||
///
|
||||
Map<String, String> variables = {};
|
||||
|
||||
/// A list of the segments in a matched path.
|
||||
///
|
||||
/// This property will contain every segment of the matched path, including
|
||||
/// constant segments. It will not contain any part of the path caught by
|
||||
/// the asterisk 'match all' token (*), however. Those are in [remainingPath].
|
||||
final List<String> segments;
|
||||
|
||||
/// If a match specification uses the 'match all' token (*),
|
||||
/// the part of the path matched by that token will be stored in this property.
|
||||
///
|
||||
/// The remaining path will will be a single string, including any path delimiters (/),
|
||||
/// but will not have a leading path delimiter.
|
||||
String? remainingPath;
|
||||
|
||||
/// An ordered list of variable names (the keys in [variables]) based on their position in the path.
|
||||
///
|
||||
/// If no path variables are present in the request, this list is empty. Only path variables that are
|
||||
/// available for the specific request are in this list. For example, if a route has two path variables,
|
||||
/// but the incoming request this [RequestPath] represents only has one variable, only that one variable
|
||||
/// will appear in this property.
|
||||
List<String> orderedVariableNames = [];
|
||||
|
||||
/// The path of the requested URI.
|
||||
///
|
||||
/// Always contains a leading '/', but never a trailing '/'.
|
||||
String get string => "/${segments.join("/")}";
|
||||
}
|
395
packages/http/lib/src/resource_controller.dart
Executable file
395
packages/http/lib/src/resource_controller.dart
Executable file
|
@ -0,0 +1,395 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ffi';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
|
||||
/// Controller for operating on an HTTP Resource.
|
||||
///
|
||||
/// [ResourceController]s provide a means to organize the logic for all operations on an HTTP resource. They also provide conveniences for handling these operations.
|
||||
///
|
||||
/// This class must be subclassed. Its instance methods handle operations on an HTTP resource. For example, the following
|
||||
/// are operations: 'GET /employees', 'GET /employees/:id' and 'POST /employees'. An instance method is assigned to handle one of these operations. For example:
|
||||
///
|
||||
/// class EmployeeController extends ResourceController {
|
||||
/// @Operation.post()
|
||||
/// Future<Response> createEmployee(...) async => Response.ok(null);
|
||||
/// }
|
||||
///
|
||||
/// Instance methods must have [Operation] annotation to respond to a request (see also [Operation.get], [Operation.post], [Operation.put] and [Operation.delete]). These
|
||||
/// methods are called *operation methods*. Operation methods also take a variable list of path variables. An operation method is called if the incoming request's method and
|
||||
/// present path variables match the operation annotation.
|
||||
///
|
||||
/// For example, the route `/employees/[:id]` contains an optional route variable named `id`.
|
||||
/// A subclass can implement two operation methods, one for when `id` was present and the other for when it was not:
|
||||
///
|
||||
/// class EmployeeController extends ResourceController {
|
||||
/// // This method gets invoked when the path is '/employees'
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getEmployees() async {
|
||||
/// return Response.ok(employees);
|
||||
/// }
|
||||
///
|
||||
/// // This method gets invoked when the path is '/employees/id'
|
||||
/// @Operation.get('id')
|
||||
/// Future<Response> getEmployees(@Bind.path("id") int id) async {
|
||||
/// return Response.ok(employees[id]);
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// If there isn't an operation method for a request, an 405 Method Not Allowed error response is sent to the client and no operation methods are called.
|
||||
///
|
||||
/// For operation methods to correctly function, a request must have previously been handled by a [Router] to parse path variables.
|
||||
///
|
||||
/// Values from a request may be bound to operation method parameters. Parameters must be annotated with [Bind.path], [Bind.query], [Bind.header], or [Bind.body].
|
||||
/// For example, the following binds an optional query string parameter 'name' to the 'name' argument:
|
||||
///
|
||||
/// class EmployeeController extends ResourceController {
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getEmployees({@Bind.query("name") String name}) async {
|
||||
/// if (name == null) {
|
||||
/// return Response.ok(employees);
|
||||
/// }
|
||||
///
|
||||
/// return Response.ok(employees.where((e) => e.name == name).toList());
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// Bindings will automatically parse values into other types and validate that requests have the desired values. See [Bind] for all possible bindings and https://conduit.io/docs/http/resource_controller/ for more details.
|
||||
///
|
||||
/// To access the request directly, use [request]. Note that the [Request.body] of [request] will be decoded prior to invoking an operation method.
|
||||
abstract class ResourceController extends Controller
|
||||
implements Recyclable<void> {
|
||||
ResourceController() {
|
||||
_runtime =
|
||||
(RuntimeContext.current.runtimes[runtimeType] as ControllerRuntime?)
|
||||
?.resourceController;
|
||||
}
|
||||
|
||||
@override
|
||||
void get recycledState => nullptr;
|
||||
|
||||
ResourceControllerRuntime? _runtime;
|
||||
|
||||
/// The request being processed by this [ResourceController].
|
||||
///
|
||||
/// It is this [ResourceController]'s responsibility to return a [Response] object for this request. Operation methods
|
||||
/// may access this request to determine how to respond to it.
|
||||
Request? request;
|
||||
|
||||
/// Parameters parsed from the URI of the request, if any exist.
|
||||
///
|
||||
/// These values are attached by a [Router] instance that precedes this [Controller]. Is null
|
||||
/// if no [Router] preceded the controller and is the empty map if there are no values. The keys
|
||||
/// are the case-sensitive name of the path variables as defined by [Router.route].
|
||||
Map<String, String> get pathVariables => request!.path.variables;
|
||||
|
||||
/// Types of content this [ResourceController] will accept.
|
||||
///
|
||||
/// If a request is sent to this instance and has an HTTP request body and the Content-Type of the body is in this list,
|
||||
/// the request will be accepted and the body will be decoded according to that Content-Type.
|
||||
///
|
||||
/// If the Content-Type of the request isn't within this list, the [ResourceController]
|
||||
/// will automatically respond with an Unsupported Media Type response.
|
||||
///
|
||||
/// By default, an instance will accept HTTP request bodies with 'application/json; charset=utf-8' encoding.
|
||||
List<ContentType> acceptedContentTypes = [ContentType.json];
|
||||
|
||||
/// The default content type of responses from this [ResourceController].
|
||||
///
|
||||
/// If the [Response.contentType] has not explicitly been set by a operation method in this controller, the controller will set
|
||||
/// that property with this value. Defaults to "application/json".
|
||||
ContentType responseContentType = ContentType.json;
|
||||
|
||||
/// Executed prior to handling a request, but after the [request] has been set.
|
||||
///
|
||||
/// This method is used to do pre-process setup and filtering. The [request] will be set, but its body will not be decoded
|
||||
/// nor will the appropriate operation method be selected yet. By default, returns the request. If this method returns a [Response], this
|
||||
/// controller will stop processing the request and immediately return the [Response] to the HTTP client.
|
||||
///
|
||||
/// May not return any other [Request] than [req].
|
||||
FutureOr<RequestOrResponse> willProcessRequest(Request req) => req;
|
||||
|
||||
/// Callback invoked prior to decoding a request body.
|
||||
///
|
||||
/// This method is invoked prior to decoding the request body.
|
||||
void willDecodeRequestBody(RequestBody body) {}
|
||||
|
||||
/// Callback to indicate when a request body has been processed.
|
||||
///
|
||||
/// This method is called after the body has been processed by the decoder, but prior to the request being
|
||||
/// handled by the selected operation method. If there is no HTTP request body,
|
||||
/// this method is not called.
|
||||
void didDecodeRequestBody(RequestBody body) {}
|
||||
|
||||
@override
|
||||
void restore(void state) {
|
||||
/* no op - fetched from static cache in Runtime */
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> handle(Request request) async {
|
||||
this.request = request;
|
||||
|
||||
final preprocessedResult = await willProcessRequest(request);
|
||||
if (preprocessedResult is Request) {
|
||||
return _process();
|
||||
} else if (preprocessedResult is Response) {
|
||||
return preprocessedResult;
|
||||
}
|
||||
|
||||
throw StateError(
|
||||
"'$runtimeType' returned invalid object from 'willProcessRequest'. Must return 'Request' or 'Response'.",
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a documented list of [APIParameter] for [operation].
|
||||
///
|
||||
/// This method will automatically create [APIParameter]s for any bound properties and operation method arguments.
|
||||
/// If an operation method requires additional parameters that cannot be bound using [Bind] annotations, override
|
||||
/// this method. When overriding this method, call the superclass' implementation and add the additional parameters
|
||||
/// to the returned list before returning the combined list.
|
||||
@mustCallSuper
|
||||
List<APIParameter>? documentOperationParameters(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
return _runtime!.documenter
|
||||
?.documentOperationParameters(this, context, operation);
|
||||
}
|
||||
|
||||
/// Returns a documented summary for [operation].
|
||||
///
|
||||
/// By default, this method returns null and the summary is derived from documentation comments
|
||||
/// above the operation method. You may override this method to manually add a summary to an operation.
|
||||
String? documentOperationSummary(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns a documented description for [operation].
|
||||
///
|
||||
/// By default, this method returns null and the description is derived from documentation comments
|
||||
/// above the operation method. You may override this method to manually add a description to an operation.
|
||||
String? documentOperationDescription(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns a documented request body for [operation].
|
||||
///
|
||||
/// If an operation method binds an [Bind.body] argument or accepts form data, this method returns a [APIRequestBody]
|
||||
/// that describes the bound body type. You may override this method to take an alternative approach or to augment the
|
||||
/// automatically generated request body documentation.
|
||||
APIRequestBody? documentOperationRequestBody(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
return _runtime!.documenter
|
||||
?.documentOperationRequestBody(this, context, operation);
|
||||
}
|
||||
|
||||
/// Returns a map of possible responses for [operation].
|
||||
///
|
||||
/// To provide documentation for an operation, you must override this method and return a map of
|
||||
/// possible responses. The key is a [String] representation of a status code (e.g., "200") and the value
|
||||
/// is an [APIResponse] object.
|
||||
Map<String, APIResponse> documentOperationResponses(
|
||||
APIDocumentContext context,
|
||||
Operation operation,
|
||||
) {
|
||||
return {"200": APIResponse("Successful response.")};
|
||||
}
|
||||
|
||||
/// Returns a list of tags for [operation].
|
||||
///
|
||||
/// By default, this method will return the name of the class. This groups each operation
|
||||
/// defined by this controller in the same tag. You may override this method
|
||||
/// to provide additional tags. You should call the superclass' implementation to retain
|
||||
/// the controller grouping tag.
|
||||
List<String> documentOperationTags(
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
) {
|
||||
final tag = "$runtimeType".replaceAll("Controller", "");
|
||||
return [tag];
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIOperation> documentOperations(
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
) {
|
||||
return _runtime!.documenter!.documentOperations(this, context, route, path);
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
_runtime!.documenter?.documentComponents(this, context);
|
||||
}
|
||||
|
||||
bool _requestContentTypeIsSupported(Request? req) {
|
||||
final incomingContentType = request!.raw.headers.contentType;
|
||||
return acceptedContentTypes.firstWhereOrNull((ct) {
|
||||
return ct.primaryType == incomingContentType!.primaryType &&
|
||||
ct.subType == incomingContentType.subType;
|
||||
}) !=
|
||||
null;
|
||||
}
|
||||
|
||||
List<String> _allowedMethodsForPathVariables(
|
||||
Iterable<String?> pathVariables,
|
||||
) {
|
||||
return _runtime!.operations
|
||||
.where((op) => op.isSuitableForRequest(null, pathVariables.toList()))
|
||||
.map((op) => op.httpMethod)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<Response> _process() async {
|
||||
if (!request!.body.isEmpty) {
|
||||
if (!_requestContentTypeIsSupported(request)) {
|
||||
return Response(HttpStatus.unsupportedMediaType, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
final operation = _runtime!.getOperationRuntime(
|
||||
request!.raw.method,
|
||||
request!.path.variables.keys.toList(),
|
||||
);
|
||||
if (operation == null) {
|
||||
throw Response(
|
||||
405,
|
||||
{
|
||||
"Allow": _allowedMethodsForPathVariables(request!.path.variables.keys)
|
||||
.join(", ")
|
||||
},
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
if (operation.scopes != null) {
|
||||
if (request!.authorization == null) {
|
||||
// todo: this should be done compile-time
|
||||
Logger("conduit").warning(
|
||||
"'$runtimeType' must be linked to channel that contains an 'Authorizer', because "
|
||||
"it uses 'Scope' annotation for one or more of its operation methods.");
|
||||
throw Response.serverError();
|
||||
}
|
||||
|
||||
if (!AuthScope.verify(operation.scopes, request!.authorization!.scopes)) {
|
||||
throw Response.forbidden(
|
||||
body: {
|
||||
"error": "insufficient_scope",
|
||||
"scope": operation.scopes!.map((s) => s.toString()).join(" ")
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!request!.body.isEmpty) {
|
||||
willDecodeRequestBody(request!.body);
|
||||
await request!.body.decode();
|
||||
didDecodeRequestBody(request!.body);
|
||||
}
|
||||
|
||||
/* Begin decoding bindings */
|
||||
final args = ResourceControllerOperationInvocationArgs();
|
||||
final errors = <String>[];
|
||||
dynamic errorCatchWrapper(ResourceControllerParameter p, f) {
|
||||
try {
|
||||
return f();
|
||||
} on ArgumentError catch (e) {
|
||||
errors.add(
|
||||
"${e.message ?? 'ArgumentError'} for ${p.locationName} value '${p.name}'",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void checkIfMissingRequiredAndEmitErrorIfSo(
|
||||
ResourceControllerParameter p,
|
||||
dynamic v,
|
||||
) {
|
||||
if (v == null && p.isRequired) {
|
||||
if (p.location == BindingType.body) {
|
||||
errors.add("missing required ${p.locationName}");
|
||||
} else {
|
||||
errors.add("missing required ${p.locationName} '${p.name ?? ""}'");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
args.positionalArguments = operation.positionalParameters
|
||||
.map((p) {
|
||||
return errorCatchWrapper(p, () {
|
||||
final value = p.decode(request);
|
||||
|
||||
checkIfMissingRequiredAndEmitErrorIfSo(p, value);
|
||||
|
||||
return value;
|
||||
});
|
||||
})
|
||||
.where((p) => p != null)
|
||||
.toList();
|
||||
|
||||
final namedEntries = operation.namedParameters
|
||||
.map((p) {
|
||||
return errorCatchWrapper(p, () {
|
||||
final value = p.decode(request);
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapEntry(p.symbolName, value);
|
||||
});
|
||||
})
|
||||
.where((p) => p != null)
|
||||
.cast<MapEntry<String, dynamic>>();
|
||||
|
||||
args.namedArguments = Map<String, dynamic>.fromEntries(namedEntries);
|
||||
|
||||
final ivarEntries = _runtime!.ivarParameters!
|
||||
.map((p) {
|
||||
return errorCatchWrapper(p, () {
|
||||
final value = p.decode(request);
|
||||
|
||||
checkIfMissingRequiredAndEmitErrorIfSo(p, value);
|
||||
|
||||
return MapEntry(p.symbolName, value);
|
||||
});
|
||||
})
|
||||
.where((e) => e != null)
|
||||
.cast<MapEntry<String, dynamic>>();
|
||||
|
||||
args.instanceVariables = Map<String, dynamic>.fromEntries(ivarEntries);
|
||||
|
||||
/* finished decoding bindings, checking for errors */
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
return Response.badRequest(body: {"error": errors.join(", ")});
|
||||
}
|
||||
|
||||
/* bind and invoke */
|
||||
_runtime!.applyRequestProperties(this, args);
|
||||
final response = await operation.invoker(this, args);
|
||||
if (!response.hasExplicitlySetContentType) {
|
||||
response.contentType = responseContentType;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
251
packages/http/lib/src/resource_controller_bindings.dart
Normal file
251
packages/http/lib/src/resource_controller_bindings.dart
Normal file
|
@ -0,0 +1,251 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Binds an instance method in [ResourceController] to an operation.
|
||||
///
|
||||
/// An operation is a request method (e.g., GET, POST) and a list of path variables. A [ResourceController] implements
|
||||
/// an operation method for each operation it handles (e.g., GET /users/:id, POST /users). A method with this annotation
|
||||
/// will be invoked when a [ResourceController] handles a request where [method] matches the request's method and
|
||||
/// *all* [pathVariables] are present in the request's path. For example:
|
||||
///
|
||||
/// class MyController extends ResourceController {
|
||||
/// @Operation.get('id')
|
||||
/// Future<Response> getOne(@Bind.path('id') int id) async {
|
||||
/// return Response.ok(objects[id]);
|
||||
/// }
|
||||
/// }
|
||||
class Operation {
|
||||
const Operation(
|
||||
this.method, [
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : _pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
const Operation.get([
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : method = "GET",
|
||||
_pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
const Operation.put([
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : method = "PUT",
|
||||
_pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
const Operation.post([
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : method = "POST",
|
||||
_pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
const Operation.delete([
|
||||
String? pathVariable1,
|
||||
String? pathVariable2,
|
||||
String? pathVariable3,
|
||||
String? pathVariable4,
|
||||
]) : method = "DELETE",
|
||||
_pathVariable1 = pathVariable1,
|
||||
_pathVariable2 = pathVariable2,
|
||||
_pathVariable3 = pathVariable3,
|
||||
_pathVariable4 = pathVariable4;
|
||||
|
||||
final String method;
|
||||
final String? _pathVariable1;
|
||||
final String? _pathVariable2;
|
||||
final String? _pathVariable3;
|
||||
final String? _pathVariable4;
|
||||
|
||||
/// Returns a list of all path variables required for this operation.
|
||||
List<String> get pathVariables {
|
||||
return [_pathVariable1, _pathVariable2, _pathVariable3, _pathVariable4]
|
||||
.fold([], (acc, s) {
|
||||
if (s != null) {
|
||||
acc.add(s);
|
||||
}
|
||||
return acc;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds elements of an HTTP request to a [ResourceController]'s operation method arguments and properties.
|
||||
///
|
||||
/// See individual constructors and [ResourceController] for more details.
|
||||
class Bind {
|
||||
/// Binds an HTTP query parameter to an [ResourceController] property or operation method argument.
|
||||
///
|
||||
/// When the incoming request's [Uri]
|
||||
/// has a query key that matches [name], the argument or property value is set to the query parameter's value. For example,
|
||||
/// the request /users?foo=bar would bind the value `bar` to the variable `foo`:
|
||||
///
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getUsers(@Bind.query("foo") String foo) async => ...;
|
||||
///
|
||||
/// [name] is compared case-sensitively, i.e. `Foo` and `foo` are different.
|
||||
///
|
||||
/// Note that if the request is a POST with content-type 'application/x-www-form-urlencoded',
|
||||
/// the query string in the request body is bound to arguments with this metadata.
|
||||
///
|
||||
/// Parameters with this metadata may be [String], [bool], or any type that implements `parse` (e.g., [int.parse] or [DateTime.parse]). It may also
|
||||
/// be a [List] of any of the allowed types, for which each query key-value pair in the request [Uri] be available in the list.
|
||||
///
|
||||
/// If the bound parameter is a positional argument in a operation method, it is required for that method. A 400 Bad Request
|
||||
/// will be sent and the operation method will not be invoked if the request does not contain the query key.
|
||||
///
|
||||
/// If the bound parameter is an optional argument in a operation method, it is optional for that method. The value of
|
||||
/// the bound property will be null if it was not present in the request.
|
||||
///
|
||||
/// If the bound parameter is a property without any additional metadata, it is optional for all methods in an [ResourceController].
|
||||
/// If the bound parameter is a property with [requiredBinding], it is required for all methods in an [ResourceController].
|
||||
const Bind.query(this.name)
|
||||
: bindingType = BindingType.query,
|
||||
accept = null,
|
||||
require = null,
|
||||
ignore = null,
|
||||
reject = null;
|
||||
|
||||
/// Binds an HTTP request header to an [ResourceController] property or operation method argument.
|
||||
///
|
||||
/// When the incoming request has a header with the name [name],
|
||||
/// the argument or property is set to the headers's value. For example,
|
||||
/// a request with the header `Authorization: Basic abcdef` would bind the value `Basic abcdef` to the `authHeader` argument:
|
||||
///
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getUsers(@Bind.header("Authorization") String authHeader) async => ...;
|
||||
///
|
||||
/// [name] is compared case-insensitively; both `Authorization` and `authorization` will match the same header.
|
||||
///
|
||||
/// Parameters with this metadata may be [String], [bool], or any type that implements `parse` (e.g., [int.parse] or [DateTime.parse]).
|
||||
///
|
||||
/// If the bound parameter is a positional argument in a operation method, it is required for that method. A 400 Bad Request
|
||||
/// will be sent and the operation method will not be invoked if the request does not contain the header.
|
||||
///
|
||||
/// If the bound parameter is an optional argument in a operation method, it is optional for that method. The value of
|
||||
/// the bound property will be null if it was not present in the request.
|
||||
///
|
||||
/// If the bound parameter is a property without any additional metadata, it is optional for all methods in an [ResourceController].
|
||||
/// If the bound parameter is a property with [requiredBinding], it is required for all methods in an [ResourceController].
|
||||
const Bind.header(this.name)
|
||||
: bindingType = BindingType.header,
|
||||
accept = null,
|
||||
require = null,
|
||||
ignore = null,
|
||||
reject = null;
|
||||
|
||||
/// Binds an HTTP request body to an [ResourceController] property or operation method argument.
|
||||
///
|
||||
/// The body of an incoming
|
||||
/// request is decoded into the bound argument or property. The argument or property *must* implement [Serializable] or be
|
||||
/// a [List<Serializable>]. If the property or argument is a [List<Serializable>], the request body must be able to be decoded into
|
||||
/// a [List] of objects (i.e., a JSON array) and [Serializable.read] is invoked for each object (see this method for parameter details).
|
||||
///
|
||||
/// Example:
|
||||
///
|
||||
///
|
||||
/// class UserController extends ResourceController {
|
||||
/// @Operation.post()
|
||||
/// Future<Response> createUser(@Bind.body() User user) async {
|
||||
/// final username = user.name;
|
||||
/// ...
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
///
|
||||
/// If the bound parameter is a positional argument in a operation method, it is required for that method.
|
||||
/// If the bound parameter is an optional argument in a operation method, it is optional for that method.
|
||||
/// If the bound parameter is a property without any additional metadata, it is optional for all methods in an [ResourceController].
|
||||
/// If the bound parameter is a property with [requiredBinding], it is required for all methods in an [ResourceController].
|
||||
///
|
||||
/// Requirements that are not met will be throw a 400 Bad Request response with the name of the missing header in the JSON error body.
|
||||
/// No operation method will be called in this case.
|
||||
///
|
||||
/// If not required and not present in a request, the bound arguments and properties will be null when the operation method is invoked.
|
||||
const Bind.body({this.accept, this.ignore, this.reject, this.require})
|
||||
: name = null,
|
||||
bindingType = BindingType.body;
|
||||
|
||||
/// Binds a route variable from [RequestPath.variables] to an [ResourceController] operation method argument.
|
||||
///
|
||||
/// Routes may have path variables, e.g., a route declared as follows has an optional path variable named 'id':
|
||||
///
|
||||
/// router.route("/users/[:id]");
|
||||
///
|
||||
/// A operation
|
||||
/// method is invoked if it has exactly the same path bindings as the incoming request's path variables. For example,
|
||||
/// consider the above route and a controller with the following operation methods:
|
||||
///
|
||||
/// class UserController extends ResourceController {
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getUsers() async => Response.ok(getAllUsers());
|
||||
/// @Operation.get('id')
|
||||
/// Future<Response> getOneUser(@Bind.path("id") int id) async => Response.ok(getUser(id));
|
||||
/// }
|
||||
///
|
||||
/// If the request path is /users/1, /users/2, etc., `getOneUser` is invoked because the path variable `id` is present and matches
|
||||
/// the [Bind.path] argument. If no path variables are present, `getUsers` is invoked.
|
||||
const Bind.path(this.name)
|
||||
: bindingType = BindingType.path,
|
||||
accept = null,
|
||||
require = null,
|
||||
ignore = null,
|
||||
reject = null;
|
||||
|
||||
final String? name;
|
||||
final BindingType bindingType;
|
||||
|
||||
final List<String>? accept;
|
||||
final List<String>? ignore;
|
||||
final List<String>? reject;
|
||||
final List<String>? require;
|
||||
}
|
||||
|
||||
enum BindingType { query, header, body, path }
|
||||
|
||||
/// Marks an [ResourceController] property binding as required.
|
||||
///
|
||||
/// Bindings are often applied to operation method arguments, in which required vs. optional
|
||||
/// is determined by whether or not the argument is in required or optional in the method signature.
|
||||
///
|
||||
/// When properties are bound, they are optional by default. Adding this metadata to a bound controller
|
||||
/// property requires that it for all operation methods.
|
||||
///
|
||||
/// For example, the following controller requires the header 'X-Request-ID' for both of its operation methods:
|
||||
///
|
||||
/// class UserController extends ResourceController {
|
||||
/// @requiredBinding
|
||||
/// @Bind.header("x-request-id")
|
||||
/// String requestID;
|
||||
///
|
||||
/// @Operation.get('id')
|
||||
/// Future<Response> getUser(@Bind.path("id") int id) async
|
||||
/// => return Response.ok(await getUserByID(id));
|
||||
///
|
||||
/// @Operation.get()
|
||||
/// Future<Response> getAllUsers() async
|
||||
/// => return Response.ok(await getUsers());
|
||||
/// }
|
||||
const RequiredBinding requiredBinding = RequiredBinding();
|
||||
|
||||
/// See [requiredBinding].
|
||||
class RequiredBinding {
|
||||
const RequiredBinding();
|
||||
}
|
227
packages/http/lib/src/resource_controller_interfaces.dart
Executable file
227
packages/http/lib/src/resource_controller_interfaces.dart
Executable file
|
@ -0,0 +1,227 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart' show IterableExtension;
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
abstract class ResourceControllerRuntime {
|
||||
List<ResourceControllerParameter>? ivarParameters;
|
||||
late List<ResourceControllerOperation> operations;
|
||||
|
||||
ResourceControllerDocumenter? documenter;
|
||||
|
||||
ResourceControllerOperation? getOperationRuntime(
|
||||
String method,
|
||||
List<String?> pathVariables,
|
||||
) {
|
||||
return operations.firstWhereOrNull(
|
||||
(op) => op.isSuitableForRequest(method, pathVariables),
|
||||
);
|
||||
}
|
||||
|
||||
void applyRequestProperties(
|
||||
ResourceController untypedController,
|
||||
ResourceControllerOperationInvocationArgs args,
|
||||
);
|
||||
}
|
||||
|
||||
abstract class ResourceControllerDocumenter {
|
||||
void documentComponents(ResourceController rc, APIDocumentContext context);
|
||||
|
||||
List<APIParameter> documentOperationParameters(
|
||||
ResourceController rc,
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
);
|
||||
|
||||
APIRequestBody? documentOperationRequestBody(
|
||||
ResourceController rc,
|
||||
APIDocumentContext context,
|
||||
Operation? operation,
|
||||
);
|
||||
|
||||
Map<String, APIOperation> documentOperations(
|
||||
ResourceController rc,
|
||||
APIDocumentContext context,
|
||||
String route,
|
||||
APIPath path,
|
||||
);
|
||||
}
|
||||
|
||||
class ResourceControllerOperation {
|
||||
ResourceControllerOperation({
|
||||
required this.scopes,
|
||||
required this.pathVariables,
|
||||
required this.httpMethod,
|
||||
required this.dartMethodName,
|
||||
required this.positionalParameters,
|
||||
required this.namedParameters,
|
||||
required this.invoker,
|
||||
});
|
||||
|
||||
final List<AuthScope>? scopes;
|
||||
final List<String> pathVariables;
|
||||
final String httpMethod;
|
||||
final String dartMethodName;
|
||||
|
||||
final List<ResourceControllerParameter> positionalParameters;
|
||||
final List<ResourceControllerParameter> namedParameters;
|
||||
|
||||
final Future<Response> Function(
|
||||
ResourceController resourceController,
|
||||
ResourceControllerOperationInvocationArgs args,
|
||||
) invoker;
|
||||
|
||||
/// Checks if a request's method and path variables will select this binder.
|
||||
///
|
||||
/// Note that [requestMethod] may be null; if this is the case, only
|
||||
/// path variables are compared.
|
||||
bool isSuitableForRequest(
|
||||
String? requestMethod,
|
||||
List<String?> requestPathVariables,
|
||||
) {
|
||||
if (requestMethod != null && requestMethod.toUpperCase() != httpMethod) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathVariables.length != requestPathVariables.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return requestPathVariables.every(pathVariables.contains);
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceControllerParameter {
|
||||
ResourceControllerParameter({
|
||||
required this.symbolName,
|
||||
required this.name,
|
||||
required this.location,
|
||||
required this.isRequired,
|
||||
required dynamic Function(dynamic input)? decoder,
|
||||
required this.type,
|
||||
required this.defaultValue,
|
||||
required this.acceptFilter,
|
||||
required this.ignoreFilter,
|
||||
required this.requireFilter,
|
||||
required this.rejectFilter,
|
||||
}) : _decoder = decoder;
|
||||
|
||||
static ResourceControllerParameter make<T>({
|
||||
required String symbolName,
|
||||
required String? name,
|
||||
required BindingType location,
|
||||
required bool isRequired,
|
||||
required dynamic Function(dynamic input) decoder,
|
||||
required dynamic defaultValue,
|
||||
required List<String>? acceptFilter,
|
||||
required List<String>? ignoreFilter,
|
||||
required List<String>? requireFilter,
|
||||
required List<String>? rejectFilter,
|
||||
}) {
|
||||
return ResourceControllerParameter(
|
||||
symbolName: symbolName,
|
||||
name: name,
|
||||
location: location,
|
||||
isRequired: isRequired,
|
||||
decoder: decoder,
|
||||
type: T,
|
||||
defaultValue: defaultValue,
|
||||
acceptFilter: acceptFilter,
|
||||
ignoreFilter: ignoreFilter,
|
||||
requireFilter: requireFilter,
|
||||
rejectFilter: rejectFilter,
|
||||
);
|
||||
}
|
||||
|
||||
final String symbolName;
|
||||
final String? name;
|
||||
final Type type;
|
||||
final dynamic defaultValue;
|
||||
final List<String>? acceptFilter;
|
||||
final List<String>? ignoreFilter;
|
||||
final List<String>? requireFilter;
|
||||
final List<String>? rejectFilter;
|
||||
|
||||
/// The location in the request that this parameter is bound to
|
||||
final BindingType location;
|
||||
|
||||
final bool isRequired;
|
||||
|
||||
final dynamic Function(dynamic input)? _decoder;
|
||||
|
||||
APIParameterLocation get apiLocation {
|
||||
switch (location) {
|
||||
case BindingType.body:
|
||||
throw StateError('body parameters do not have a location');
|
||||
case BindingType.header:
|
||||
return APIParameterLocation.header;
|
||||
case BindingType.query:
|
||||
return APIParameterLocation.query;
|
||||
case BindingType.path:
|
||||
return APIParameterLocation.path;
|
||||
}
|
||||
}
|
||||
|
||||
String get locationName {
|
||||
switch (location) {
|
||||
case BindingType.query:
|
||||
return "query";
|
||||
case BindingType.body:
|
||||
return "body";
|
||||
case BindingType.header:
|
||||
return "header";
|
||||
case BindingType.path:
|
||||
return "path";
|
||||
}
|
||||
}
|
||||
|
||||
dynamic decode(Request? request) {
|
||||
switch (location) {
|
||||
case BindingType.query:
|
||||
{
|
||||
final queryParameters = request!.raw.uri.queryParametersAll;
|
||||
final value = request.body.isFormData
|
||||
? request.body.as<Map<String, List<String>>>()[name!]
|
||||
: queryParameters[name!];
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return _decoder!(value);
|
||||
}
|
||||
|
||||
case BindingType.body:
|
||||
{
|
||||
if (request!.body.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
return _decoder!(request.body);
|
||||
}
|
||||
case BindingType.header:
|
||||
{
|
||||
final header = request!.raw.headers[name!];
|
||||
if (header == null) {
|
||||
return null;
|
||||
}
|
||||
return _decoder!(header);
|
||||
}
|
||||
|
||||
case BindingType.path:
|
||||
{
|
||||
final path = request!.path.variables[name];
|
||||
if (path == null) {
|
||||
return null;
|
||||
}
|
||||
return _decoder!(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceControllerOperationInvocationArgs {
|
||||
late Map<String, dynamic> instanceVariables;
|
||||
late Map<String, dynamic> namedArguments;
|
||||
late List<dynamic> positionalArguments;
|
||||
}
|
43
packages/http/lib/src/resource_controller_scope.dart
Normal file
43
packages/http/lib/src/resource_controller_scope.dart
Normal file
|
@ -0,0 +1,43 @@
|
|||
import 'package:protevus_auth/auth.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Allows [ResourceController]s to have different scope for each operation method.
|
||||
///
|
||||
/// This type is used as an annotation to an operation method declared in a [ResourceController].
|
||||
///
|
||||
/// If an operation method has this annotation, an incoming [Request.authorization] must have sufficient
|
||||
/// scope for the method to be executed. If not, a 403 Forbidden response is sent. Sufficient scope
|
||||
/// requires that *every* listed scope is met by the request.
|
||||
///
|
||||
/// The typical use case is to require more scope for an editing action than a viewing action. Example:
|
||||
///
|
||||
/// class NoteController extends ResourceController {
|
||||
/// @Scope(['notes.readonly']);
|
||||
/// @Operation.get('id')
|
||||
/// Future<Response> getNote(@Bind.path('id') int id) async {
|
||||
/// ...
|
||||
/// }
|
||||
///
|
||||
/// @Scope(['notes']);
|
||||
/// @Operation.post()
|
||||
/// Future<Response> createNote() async {
|
||||
/// ...
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// An [Authorizer] *must* have been previously linked in the channel. Otherwise, an error is thrown
|
||||
/// at runtime. Example:
|
||||
///
|
||||
/// router
|
||||
/// .route("/notes/[:id]")
|
||||
/// .link(() => Authorizer.bearer(authServer))
|
||||
/// .link(() => NoteController());
|
||||
class Scope {
|
||||
/// Add to [ResourceController] operation method to require authorization scope.
|
||||
///
|
||||
/// An incoming [Request.authorization] must have sufficient scope for all [scopes].
|
||||
const Scope(this.scopes);
|
||||
|
||||
/// The list of authorization scopes required.
|
||||
final List<String> scopes;
|
||||
}
|
225
packages/http/lib/src/response.dart
Normal file
225
packages/http/lib/src/response.dart
Normal file
|
@ -0,0 +1,225 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Represents the information in an HTTP response.
|
||||
///
|
||||
/// This object can be used to write an HTTP response and contains conveniences
|
||||
/// for creating these objects.
|
||||
class Response implements RequestOrResponse {
|
||||
/// The default constructor.
|
||||
///
|
||||
/// There exist convenience constructors for common response status codes
|
||||
/// and you should prefer to use those.
|
||||
Response(int this.statusCode, Map<String, dynamic>? headers, dynamic body) {
|
||||
this.body = body;
|
||||
this.headers = LinkedHashMap<String, dynamic>(
|
||||
equals: (a, b) => a.toLowerCase() == b.toLowerCase(),
|
||||
hashCode: (key) => key.toLowerCase().hashCode);
|
||||
this.headers.addAll(headers ?? {});
|
||||
}
|
||||
|
||||
/// Represents a 200 response.
|
||||
Response.ok(dynamic body, {Map<String, dynamic>? headers})
|
||||
: this(HttpStatus.ok, headers, body);
|
||||
|
||||
/// Represents a 201 response.
|
||||
///
|
||||
/// The [location] is a URI that is added as the Location header.
|
||||
Response.created(
|
||||
String location, {
|
||||
dynamic body,
|
||||
Map<String, dynamic>? headers,
|
||||
}) : this(
|
||||
HttpStatus.created,
|
||||
_headersWith(headers, {HttpHeaders.locationHeader: location}),
|
||||
body,
|
||||
);
|
||||
|
||||
/// Represents a 202 response.
|
||||
Response.accepted({Map<String, dynamic>? headers})
|
||||
: this(HttpStatus.accepted, headers, null);
|
||||
|
||||
/// Represents a 204 response.
|
||||
Response.noContent({Map<String, dynamic>? headers})
|
||||
: this(HttpStatus.noContent, headers, null);
|
||||
|
||||
/// Represents a 304 response.
|
||||
///
|
||||
/// Where [lastModified] is the last modified date of the resource
|
||||
/// and [cachePolicy] is the same policy as applied when this resource was first fetched.
|
||||
Response.notModified(DateTime lastModified, this.cachePolicy) {
|
||||
statusCode = HttpStatus.notModified;
|
||||
headers = {HttpHeaders.lastModifiedHeader: HttpDate.format(lastModified)};
|
||||
}
|
||||
|
||||
/// Represents a 400 response.
|
||||
Response.badRequest({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.badRequest, headers, body);
|
||||
|
||||
/// Represents a 401 response.
|
||||
Response.unauthorized({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.unauthorized, headers, body);
|
||||
|
||||
/// Represents a 403 response.
|
||||
Response.forbidden({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.forbidden, headers, body);
|
||||
|
||||
/// Represents a 404 response.
|
||||
Response.notFound({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.notFound, headers, body);
|
||||
|
||||
/// Represents a 409 response.
|
||||
Response.conflict({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.conflict, headers, body);
|
||||
|
||||
/// Represents a 410 response.
|
||||
Response.gone({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.gone, headers, body);
|
||||
|
||||
/// Represents a 500 response.
|
||||
Response.serverError({Map<String, dynamic>? headers, dynamic body})
|
||||
: this(HttpStatus.internalServerError, headers, body);
|
||||
|
||||
/// The default value of a [contentType].
|
||||
///
|
||||
/// If no [contentType] is set for an instance, this is the value used. By default, this value is
|
||||
/// [ContentType.json].
|
||||
static ContentType defaultContentType = ContentType.json;
|
||||
|
||||
/// An object representing the body of the [Response], which will be encoded when used to [Request.respond].
|
||||
///
|
||||
/// This is typically a map or list of maps that will be encoded to JSON. If the [body] was previously set with a [Serializable] object
|
||||
/// or a list of [Serializable] objects, this property will be the already serialized (but not encoded) body.
|
||||
dynamic get body => _body;
|
||||
|
||||
/// Sets the unencoded response body.
|
||||
///
|
||||
/// This may be any value that can be encoded into an HTTP response body. If this value is a [Serializable] or a [List] of [Serializable],
|
||||
/// each instance of [Serializable] will transformed via its [Serializable.asMap] method before being set.
|
||||
set body(dynamic initialResponseBody) {
|
||||
dynamic serializedBody;
|
||||
if (initialResponseBody is Serializable) {
|
||||
serializedBody = initialResponseBody.asMap();
|
||||
} else if (initialResponseBody is List<Serializable>) {
|
||||
serializedBody =
|
||||
initialResponseBody.map((value) => value.asMap()).toList();
|
||||
}
|
||||
|
||||
_body = serializedBody ?? initialResponseBody;
|
||||
}
|
||||
|
||||
dynamic _body;
|
||||
|
||||
/// Whether or not this instance should buffer its output or send it right away.
|
||||
///
|
||||
/// In general, output should be buffered and therefore this value defaults to 'true'.
|
||||
///
|
||||
/// For long-running requests where data may be made available over time,
|
||||
/// this value can be set to 'false' to emit bytes to the HTTP client
|
||||
/// as they are provided.
|
||||
///
|
||||
/// This property has no effect if [body] is not a [Stream].
|
||||
bool bufferOutput = true;
|
||||
|
||||
/// Map of headers to send in this response.
|
||||
///
|
||||
/// Where the key is the Header name and value is the Header value. Values are added to the Response body
|
||||
/// according to [HttpHeaders.add].
|
||||
///
|
||||
/// The keys of this map are case-insensitive - they will always be lowercased. If the value is a [List],
|
||||
/// each item in the list will be added separately for the same header name.
|
||||
///
|
||||
/// See [contentType] for behavior when setting 'content-type' in this property.
|
||||
Map<String, dynamic> get headers => _headers;
|
||||
set headers(Map<String, dynamic> h) {
|
||||
_headers.clear();
|
||||
_headers.addAll(h);
|
||||
}
|
||||
|
||||
final Map<String, dynamic> _headers = LinkedHashMap<String, Object?>(
|
||||
equals: (a, b) => a.toLowerCase() == b.toLowerCase(),
|
||||
hashCode: (key) => key.toLowerCase().hashCode);
|
||||
|
||||
/// The HTTP status code of this response.
|
||||
int? statusCode;
|
||||
|
||||
/// Cache policy that sets 'Cache-Control' headers for this instance.
|
||||
///
|
||||
/// If null (the default), no 'Cache-Control' headers are applied. Otherwise,
|
||||
/// the value returned by [CachePolicy.headerValue] will be applied to this instance for the header name
|
||||
/// 'Cache-Control'.
|
||||
CachePolicy? cachePolicy;
|
||||
|
||||
/// The content type of the body of this response.
|
||||
///
|
||||
/// Defaults to [defaultContentType]. This response's body will be encoded according to this value.
|
||||
/// The Content-Type header of the HTTP response will always be set according to this value.
|
||||
///
|
||||
/// If this value is set directly, then this instance's Content-Type will be that value.
|
||||
/// If this value is not set, then the [headers] property is checked for the key 'content-type'.
|
||||
/// If the key is not present in [headers], this property's value is [defaultContentType].
|
||||
///
|
||||
/// If the key is present and the value is a [String], this value is the result of passing the value to [ContentType.parse].
|
||||
/// If the key is present and the value is a [ContentType], this property is equal to that value.
|
||||
ContentType? get contentType {
|
||||
if (_contentType != null) {
|
||||
return _contentType;
|
||||
}
|
||||
|
||||
final inHeaders = _headers[HttpHeaders.contentTypeHeader];
|
||||
if (inHeaders == null) {
|
||||
return defaultContentType;
|
||||
}
|
||||
|
||||
if (inHeaders is ContentType) {
|
||||
return inHeaders;
|
||||
}
|
||||
|
||||
if (inHeaders is String) {
|
||||
return ContentType.parse(inHeaders);
|
||||
}
|
||||
|
||||
throw StateError(
|
||||
"Invalid content-type response header. Is not 'String' or 'ContentType'.",
|
||||
);
|
||||
}
|
||||
|
||||
set contentType(ContentType? t) {
|
||||
_contentType = t;
|
||||
}
|
||||
|
||||
ContentType? _contentType;
|
||||
|
||||
/// Whether or nor this instance has explicitly has its [contentType] property.
|
||||
///
|
||||
/// This value indicates whether or not [contentType] has been set, or is still using its default value.
|
||||
bool get hasExplicitlySetContentType => _contentType != null;
|
||||
|
||||
/// Whether or not the body object of this instance should be encoded.
|
||||
///
|
||||
/// By default, a body object is encoded according to its [contentType] and the corresponding
|
||||
/// [Codec] in [CodecRegistry].
|
||||
///
|
||||
/// If this instance's body object has already been encoded as a list of bytes by some other mechanism,
|
||||
/// this property should be set to false to avoid the encoding process. This is useful when streaming a file
|
||||
/// from disk where it is already stored as an encoded list of bytes.
|
||||
bool encodeBody = true;
|
||||
|
||||
static Map<String, dynamic> _headersWith(
|
||||
Map<String, dynamic>? inputHeaders,
|
||||
Map<String, dynamic> otherHeaders,
|
||||
) {
|
||||
final m = LinkedHashMap<String, Object?>(
|
||||
equals: (a, b) => a.toLowerCase() == b.toLowerCase(),
|
||||
hashCode: (key) => key.toLowerCase().hashCode);
|
||||
if (inputHeaders != null) {
|
||||
m.addAll(inputHeaders);
|
||||
}
|
||||
m.addAll(otherHeaders);
|
||||
return m;
|
||||
}
|
||||
}
|
223
packages/http/lib/src/route_node.dart
Normal file
223
packages/http/lib/src/route_node.dart
Normal file
|
@ -0,0 +1,223 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
class RouteSegment {
|
||||
RouteSegment(String segment) {
|
||||
if (segment == "*") {
|
||||
isRemainingMatcher = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final regexIndex = segment.indexOf("(");
|
||||
if (regexIndex != -1) {
|
||||
final regexText = segment.substring(regexIndex + 1, segment.length - 1);
|
||||
matcher = RegExp(regexText);
|
||||
|
||||
segment = segment.substring(0, regexIndex);
|
||||
}
|
||||
|
||||
if (segment.startsWith(":")) {
|
||||
variableName = segment.substring(1, segment.length);
|
||||
} else if (regexIndex == -1) {
|
||||
literal = segment;
|
||||
}
|
||||
}
|
||||
|
||||
RouteSegment.direct({
|
||||
this.literal,
|
||||
this.variableName,
|
||||
String? expression,
|
||||
bool matchesAnything = false,
|
||||
}) {
|
||||
isRemainingMatcher = matchesAnything;
|
||||
if (expression != null) {
|
||||
matcher = RegExp(expression);
|
||||
}
|
||||
}
|
||||
|
||||
String? literal;
|
||||
String? variableName;
|
||||
RegExp? matcher;
|
||||
|
||||
bool get isLiteralMatcher =>
|
||||
!isRemainingMatcher && !isVariable && !hasRegularExpression;
|
||||
|
||||
bool get hasRegularExpression => matcher != null;
|
||||
|
||||
bool get isVariable => variableName != null;
|
||||
bool isRemainingMatcher = false;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is RouteSegment &&
|
||||
literal == other.literal &&
|
||||
variableName == other.variableName &&
|
||||
isRemainingMatcher == other.isRemainingMatcher &&
|
||||
matcher?.pattern == other.matcher?.pattern;
|
||||
|
||||
@override
|
||||
int get hashCode => (literal ?? variableName).hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
if (isLiteralMatcher) {
|
||||
return literal ?? "";
|
||||
}
|
||||
|
||||
if (isVariable) {
|
||||
return variableName ?? "";
|
||||
}
|
||||
|
||||
if (hasRegularExpression) {
|
||||
return "(${matcher!.pattern})";
|
||||
}
|
||||
|
||||
return "*";
|
||||
}
|
||||
}
|
||||
|
||||
class RouteNode {
|
||||
RouteNode(List<RouteSpecification?> specs, {int depth = 0, RegExp? matcher}) {
|
||||
patternMatcher = matcher;
|
||||
|
||||
final terminatedAtThisDepth =
|
||||
specs.where((spec) => spec?.segments.length == depth).toList();
|
||||
if (terminatedAtThisDepth.length > 1) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Cannot disambiguate from the following routes: $terminatedAtThisDepth.",
|
||||
);
|
||||
} else if (terminatedAtThisDepth.length == 1) {
|
||||
specification = terminatedAtThisDepth.first;
|
||||
}
|
||||
|
||||
final remainingSpecifications = List<RouteSpecification?>.from(
|
||||
specs.where((spec) => depth != spec?.segments.length),
|
||||
);
|
||||
|
||||
final Set<String> childEqualitySegments = Set.from(
|
||||
remainingSpecifications
|
||||
.where((spec) => spec?.segments[depth].isLiteralMatcher ?? false)
|
||||
.map((spec) => spec!.segments[depth].literal),
|
||||
);
|
||||
|
||||
for (final childSegment in childEqualitySegments) {
|
||||
final childrenBeginningWithThisSegment = remainingSpecifications
|
||||
.where((spec) => spec?.segments[depth].literal == childSegment)
|
||||
.toList();
|
||||
equalityChildren[childSegment] =
|
||||
RouteNode(childrenBeginningWithThisSegment, depth: depth + 1);
|
||||
remainingSpecifications
|
||||
.removeWhere(childrenBeginningWithThisSegment.contains);
|
||||
}
|
||||
|
||||
final takeAllSegment = remainingSpecifications.firstWhere(
|
||||
(spec) => spec?.segments[depth].isRemainingMatcher ?? false,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (takeAllSegment != null) {
|
||||
takeAllChild = RouteNode.withSpecification(takeAllSegment);
|
||||
remainingSpecifications.removeWhere(
|
||||
(spec) => spec?.segments[depth].isRemainingMatcher ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
final Set<String?> childPatternedSegments = Set.from(
|
||||
remainingSpecifications
|
||||
.map((spec) => spec?.segments[depth].matcher?.pattern),
|
||||
);
|
||||
|
||||
patternedChildren = childPatternedSegments.map((pattern) {
|
||||
final childrenWithThisPattern = remainingSpecifications
|
||||
.where((spec) => spec?.segments[depth].matcher?.pattern == pattern)
|
||||
.toList();
|
||||
|
||||
if (childrenWithThisPattern
|
||||
.any((spec) => spec?.segments[depth].matcher == null) &&
|
||||
childrenWithThisPattern
|
||||
.any((spec) => spec?.segments[depth].matcher != null)) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Cannot disambiguate from the following routes, as one of them will match anything: $childrenWithThisPattern.",
|
||||
);
|
||||
}
|
||||
|
||||
return RouteNode(
|
||||
childrenWithThisPattern,
|
||||
depth: depth + 1,
|
||||
matcher: childrenWithThisPattern.first?.segments[depth].matcher,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
RouteNode.withSpecification(this.specification);
|
||||
|
||||
// Regular expression matcher for this node. May be null.
|
||||
RegExp? patternMatcher;
|
||||
Controller? get controller => specification?.controller;
|
||||
RouteSpecification? specification;
|
||||
|
||||
// Includes children that are variables with and without regex patterns
|
||||
List<RouteNode> patternedChildren = [];
|
||||
|
||||
// Includes children that are literal path segments that can be matched with simple string equality
|
||||
Map<String, RouteNode> equalityChildren = {};
|
||||
|
||||
// Valid if has child that is a take all (*) segment.
|
||||
RouteNode? takeAllChild;
|
||||
|
||||
RouteNode? nodeForPathSegments(
|
||||
Iterator<String> requestSegments,
|
||||
RequestPath path,
|
||||
) {
|
||||
if (!requestSegments.moveNext()) {
|
||||
return this;
|
||||
}
|
||||
|
||||
final nextSegment = requestSegments.current;
|
||||
|
||||
if (equalityChildren.containsKey(nextSegment)) {
|
||||
return equalityChildren[nextSegment]!
|
||||
.nodeForPathSegments(requestSegments, path);
|
||||
}
|
||||
|
||||
for (final node in patternedChildren) {
|
||||
if (node.patternMatcher == null) {
|
||||
// This is a variable with no regular expression
|
||||
return node.nodeForPathSegments(requestSegments, path);
|
||||
}
|
||||
|
||||
if (node.patternMatcher!.firstMatch(nextSegment) != null) {
|
||||
// This segment has a regular expression
|
||||
return node.nodeForPathSegments(requestSegments, path);
|
||||
}
|
||||
}
|
||||
|
||||
// If this is null, then we return null from this method
|
||||
// and the router knows we didn't find a match.
|
||||
return takeAllChild;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString({int depth = 0}) {
|
||||
final buf = StringBuffer();
|
||||
for (var i = 0; i < depth; i++) {
|
||||
buf.write("\t");
|
||||
}
|
||||
|
||||
if (patternMatcher != null) {
|
||||
buf.write("(match: ${patternMatcher!.pattern})");
|
||||
}
|
||||
|
||||
buf.writeln(
|
||||
"Controller: ${specification?.controller?.nextController?.runtimeType}",
|
||||
);
|
||||
equalityChildren.forEach((seg, spec) {
|
||||
for (var i = 0; i < depth; i++) {
|
||||
buf.write("\t");
|
||||
}
|
||||
|
||||
buf.writeln("/$seg");
|
||||
buf.writeln(spec.toString(depth: depth + 1));
|
||||
});
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
}
|
157
packages/http/lib/src/route_specification.dart
Normal file
157
packages/http/lib/src/route_specification.dart
Normal file
|
@ -0,0 +1,157 @@
|
|||
import 'package:protevus_http/http.dart';
|
||||
|
||||
/// Specifies a matchable route path.
|
||||
///
|
||||
/// Contains [RouteSegment]s for each path segment. This class is used internally by [Router].
|
||||
class RouteSpecification {
|
||||
/// Creates a [RouteSpecification] from a [String].
|
||||
///
|
||||
/// The [patternString] must be stripped of any optionals.
|
||||
RouteSpecification(String patternString) {
|
||||
segments = _splitPathSegments(patternString);
|
||||
variableNames = segments
|
||||
.where((e) => e.isVariable)
|
||||
.map((e) => e.variableName!)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<RouteSpecification> specificationsForRoutePattern(
|
||||
String routePattern,
|
||||
) {
|
||||
return _pathsFromRoutePattern(routePattern)
|
||||
.map((path) => RouteSpecification(path))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// A list of this specification's [RouteSegment]s.
|
||||
late List<RouteSegment> segments;
|
||||
|
||||
/// A list of all variables in this route.
|
||||
late List<String> variableNames;
|
||||
|
||||
/// A reference back to the [Controller] to be used when this specification is matched.
|
||||
Controller? controller;
|
||||
|
||||
@override
|
||||
String toString() => segments.join("/");
|
||||
}
|
||||
|
||||
List<String> _pathsFromRoutePattern(String inputPattern) {
|
||||
var routePattern = inputPattern;
|
||||
var endingOptionalCloseCount = 0;
|
||||
while (routePattern.endsWith("]")) {
|
||||
routePattern = routePattern.substring(0, routePattern.length - 1);
|
||||
endingOptionalCloseCount++;
|
||||
}
|
||||
|
||||
final chars = routePattern.codeUnits;
|
||||
final patterns = <String>[];
|
||||
final buffer = StringBuffer();
|
||||
final openOptional = '['.codeUnitAt(0);
|
||||
final openExpression = '('.codeUnitAt(0);
|
||||
final closeExpression = ')'.codeUnitAt(0);
|
||||
|
||||
bool insideExpression = false;
|
||||
for (var i = 0; i < chars.length; i++) {
|
||||
final code = chars[i];
|
||||
|
||||
if (code == openExpression) {
|
||||
if (insideExpression) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$routePattern' cannot use expression that contains '(' or ')'",
|
||||
);
|
||||
} else {
|
||||
buffer.writeCharCode(code);
|
||||
insideExpression = true;
|
||||
}
|
||||
} else if (code == closeExpression) {
|
||||
if (insideExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
insideExpression = false;
|
||||
} else {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$routePattern' cannot use expression that contains '(' or ')'",
|
||||
);
|
||||
}
|
||||
} else if (code == openOptional) {
|
||||
if (insideExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
} else {
|
||||
patterns.add(buffer.toString());
|
||||
}
|
||||
} else {
|
||||
buffer.writeCharCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (insideExpression) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$routePattern' has unterminated regular expression.",
|
||||
);
|
||||
}
|
||||
|
||||
if (endingOptionalCloseCount != patterns.length) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$routePattern' does not close all optionals.",
|
||||
);
|
||||
}
|
||||
|
||||
// Add the final pattern - if no optionals, this is the only pattern.
|
||||
patterns.add(buffer.toString());
|
||||
|
||||
return patterns;
|
||||
}
|
||||
|
||||
List<RouteSegment> _splitPathSegments(String inputPath) {
|
||||
var path = inputPath;
|
||||
// Once we've gotten into this method, the path has been validated for optionals and regex and optionals have been removed.
|
||||
|
||||
// Trim leading and trailing
|
||||
while (path.startsWith("/")) {
|
||||
path = path.substring(1, path.length);
|
||||
}
|
||||
while (path.endsWith("/")) {
|
||||
path = path.substring(0, path.length - 1);
|
||||
}
|
||||
|
||||
final segments = <String>[];
|
||||
final chars = path.codeUnits;
|
||||
var buffer = StringBuffer();
|
||||
|
||||
final openExpression = '('.codeUnitAt(0);
|
||||
final closeExpression = ')'.codeUnitAt(0);
|
||||
final pathDelimiter = '/'.codeUnitAt(0);
|
||||
bool insideExpression = false;
|
||||
|
||||
for (var i = 0; i < path.length; i++) {
|
||||
final code = chars[i];
|
||||
|
||||
if (code == openExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
insideExpression = true;
|
||||
} else if (code == closeExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
insideExpression = false;
|
||||
} else if (code == pathDelimiter) {
|
||||
if (insideExpression) {
|
||||
buffer.writeCharCode(code);
|
||||
} else {
|
||||
segments.add(buffer.toString());
|
||||
buffer = StringBuffer();
|
||||
}
|
||||
} else {
|
||||
buffer.writeCharCode(code);
|
||||
}
|
||||
}
|
||||
|
||||
if (segments.any((seg) => seg == "")) {
|
||||
throw ArgumentError(
|
||||
"Router compilation failed. Route pattern '$path' contains an empty path segment.",
|
||||
);
|
||||
}
|
||||
|
||||
// Add final
|
||||
segments.add(buffer.toString());
|
||||
|
||||
return segments.map((seg) => RouteSegment(seg)).toList();
|
||||
}
|
242
packages/http/lib/src/router.dart
Normal file
242
packages/http/lib/src/router.dart
Normal file
|
@ -0,0 +1,242 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
|
||||
/// Determines which [Controller] should receive a [Request] based on its path.
|
||||
///
|
||||
/// A router is a [Controller] that evaluates the path of a [Request] and determines which controller should be the next to receive it.
|
||||
/// Valid paths for a [Router] are called *routes* and are added to a [Router] via [route].
|
||||
///
|
||||
/// Each [route] creates a new [Controller] that will receive all requests whose path match the route pattern.
|
||||
/// If a request path does not match one of the registered routes, [Router] responds with 404 Not Found and does not pass
|
||||
/// the request to another controller.
|
||||
///
|
||||
/// Unlike most [Controller]s, a [Router] may have multiple controllers it sends requests to. In most applications,
|
||||
/// a [Router] is the [ApplicationChannel.entryPoint].
|
||||
class Router extends Controller {
|
||||
/// Creates a new [Router].
|
||||
Router({String? basePath, Future Function(Request)? notFoundHandler})
|
||||
: _unmatchedController = notFoundHandler,
|
||||
_basePathSegments =
|
||||
basePath?.split("/").where((str) => str.isNotEmpty).toList() ?? [] {
|
||||
policy?.allowCredentials = false;
|
||||
}
|
||||
|
||||
final _RootNode _root = _RootNode();
|
||||
final List<_RouteController> _routeControllers = [];
|
||||
final List<String> _basePathSegments;
|
||||
final Function(Request)? _unmatchedController;
|
||||
|
||||
/// A prefix for all routes on this instance.
|
||||
///
|
||||
/// If this value is non-null, each [route] is prefixed by this value.
|
||||
///
|
||||
/// For example, if a route is "/users" and the value of this property is "/api",
|
||||
/// a request's path must be "/api/users" to match the route.
|
||||
///
|
||||
/// Trailing and leading slashes have no impact on this value.
|
||||
String get basePath => "/${_basePathSegments.join("/")}";
|
||||
|
||||
/// Adds a route that [Controller]s can be linked to.
|
||||
///
|
||||
/// Routers allow for multiple linked controllers. A request that matches [pattern]
|
||||
/// will be sent to the controller linked to this method's return value.
|
||||
///
|
||||
/// The [pattern] must follow the rules of route patterns (see also http://conduit.io/docs/http/routing/).
|
||||
///
|
||||
/// A pattern consists of one or more path segments, e.g. "/path" or "/path/to".
|
||||
///
|
||||
/// A path segment can be:
|
||||
///
|
||||
/// - A literal string (e.g. `users`)
|
||||
/// - A path variable: a literal string prefixed with `:` (e.g. `:id`)
|
||||
/// - A wildcard: the character `*`
|
||||
///
|
||||
/// A path variable may contain a regular expression by placing the expression in parentheses immediately after the variable name. (e.g. `:id(/d+)`).
|
||||
///
|
||||
/// A path segment is required by default. Path segments may be marked as optional
|
||||
/// by wrapping them in square brackets `[]`.
|
||||
///
|
||||
/// Here are some example routes:
|
||||
///
|
||||
/// /users
|
||||
/// /users/:id
|
||||
/// /users/[:id]
|
||||
/// /users/:id/friends/[:friendID]
|
||||
/// /locations/:name([^0-9])
|
||||
/// /files/*
|
||||
///
|
||||
Linkable route(String pattern) {
|
||||
final routeController = _RouteController(
|
||||
RouteSpecification.specificationsForRoutePattern(pattern),
|
||||
);
|
||||
_routeControllers.add(routeController);
|
||||
return routeController;
|
||||
}
|
||||
|
||||
@override
|
||||
void didAddToChannel() {
|
||||
_root.node =
|
||||
RouteNode(_routeControllers.expand((rh) => rh.specifications).toList());
|
||||
|
||||
for (final c in _routeControllers) {
|
||||
c.didAddToChannel();
|
||||
}
|
||||
}
|
||||
|
||||
/// Routers override this method to throw an exception. Use [route] instead.
|
||||
@override
|
||||
Linkable link(Controller Function() generatorFunction) {
|
||||
throw ArgumentError(
|
||||
"Invalid link. 'Router' cannot directly link to controllers. Use 'route'.",
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Linkable? linkFunction(
|
||||
FutureOr<RequestOrResponse?> Function(Request request) handle,
|
||||
) {
|
||||
throw ArgumentError(
|
||||
"Invalid link. 'Router' cannot directly link to functions. Use 'route'.",
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future receive(Request req) async {
|
||||
Controller next;
|
||||
try {
|
||||
var requestURISegmentIterator = req.raw.uri.pathSegments.iterator;
|
||||
|
||||
if (req.raw.uri.pathSegments.isEmpty) {
|
||||
requestURISegmentIterator = [""].iterator;
|
||||
}
|
||||
|
||||
for (var i = 0; i < _basePathSegments.length; i++) {
|
||||
requestURISegmentIterator.moveNext();
|
||||
if (_basePathSegments[i] != requestURISegmentIterator.current) {
|
||||
await _handleUnhandledRequest(req);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
final node =
|
||||
_root.node!.nodeForPathSegments(requestURISegmentIterator, req.path);
|
||||
if (node?.specification == null) {
|
||||
await _handleUnhandledRequest(req);
|
||||
return null;
|
||||
}
|
||||
req.path.setSpecification(
|
||||
node!.specification!,
|
||||
segmentOffset: _basePathSegments.length,
|
||||
);
|
||||
next = node.controller!;
|
||||
} catch (any, stack) {
|
||||
return handleError(req, any, stack);
|
||||
}
|
||||
|
||||
// This line is intentionally outside of the try block
|
||||
// so that this object doesn't handle exceptions for 'next'.
|
||||
return next.receive(req);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> handle(Request request) {
|
||||
throw StateError("Router invoked handle. This is a bug.");
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, APIPath> documentPaths(APIDocumentContext context) {
|
||||
return _routeControllers.fold(<String, APIPath>{}, (prev, elem) {
|
||||
prev.addAll(elem.documentPaths(context));
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void documentComponents(APIDocumentContext context) {
|
||||
for (final controller in _routeControllers) {
|
||||
controller.documentComponents(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return _root.node.toString();
|
||||
}
|
||||
|
||||
Future _handleUnhandledRequest(Request req) async {
|
||||
if (_unmatchedController != null) {
|
||||
return _unmatchedController(req);
|
||||
}
|
||||
final response = Response.notFound();
|
||||
if (req.acceptsContentType(ContentType.html)) {
|
||||
response
|
||||
..body = "<html><h3>404 Not Found</h3></html>"
|
||||
..contentType = ContentType.html;
|
||||
}
|
||||
|
||||
applyCORSHeadersIfNecessary(req, response);
|
||||
await req.respond(response);
|
||||
logger.info(req.toDebugString());
|
||||
}
|
||||
}
|
||||
|
||||
class _RootNode {
|
||||
RouteNode? node;
|
||||
}
|
||||
|
||||
class _RouteController extends Controller {
|
||||
_RouteController(this.specifications) {
|
||||
for (final p in specifications) {
|
||||
p.controller = this;
|
||||
}
|
||||
}
|
||||
|
||||
/// Route specifications for this controller.
|
||||
final List<RouteSpecification> specifications;
|
||||
|
||||
@override
|
||||
Map<String, APIPath> documentPaths(APIDocumentContext components) {
|
||||
return specifications.fold(<String, APIPath>{}, (pathMap, spec) {
|
||||
final elements = spec.segments.map((rs) {
|
||||
if (rs.isLiteralMatcher) {
|
||||
return rs.literal;
|
||||
} else if (rs.isVariable) {
|
||||
return "{${rs.variableName}}";
|
||||
} else if (rs.isRemainingMatcher) {
|
||||
return "{path}";
|
||||
}
|
||||
throw StateError("unknown specification");
|
||||
}).join("/");
|
||||
final pathKey = "/$elements";
|
||||
|
||||
final path = APIPath()
|
||||
..parameters = spec.variableNames
|
||||
.map((pathVar) => APIParameter.path(pathVar))
|
||||
.toList();
|
||||
|
||||
if (spec.segments.any((seg) => seg.isRemainingMatcher)) {
|
||||
path.parameters.add(
|
||||
APIParameter.path("path")
|
||||
..description =
|
||||
"This path variable may contain slashes '/' and may be empty.",
|
||||
);
|
||||
}
|
||||
|
||||
path.operations =
|
||||
spec.controller!.documentOperations(components, pathKey, path);
|
||||
|
||||
pathMap[pathKey] = path;
|
||||
|
||||
return pathMap;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<RequestOrResponse> handle(Request request) {
|
||||
return request;
|
||||
}
|
||||
}
|
119
packages/http/lib/src/serializable.dart
Normal file
119
packages/http/lib/src/serializable.dart
Normal file
|
@ -0,0 +1,119 @@
|
|||
import 'package:protevus_openapi/documentable.dart';
|
||||
import 'package:protevus_http/http.dart';
|
||||
import 'package:protevus_openapi/v3.dart';
|
||||
import 'package:protevus_runtime/runtime.dart';
|
||||
|
||||
/// Interface for serializable instances to be decoded from an HTTP request body and encoded to an HTTP response body.
|
||||
///
|
||||
/// Implementers of this interface may be a [Response.body] and bound with an [Bind.body] in [ResourceController].
|
||||
abstract class Serializable {
|
||||
/// Returns an [APISchemaObject] describing this object's type.
|
||||
///
|
||||
/// The returned [APISchemaObject] will be of type [APIType.object]. By default, each instance variable
|
||||
/// of the receiver's type will be a property of the return value.
|
||||
APISchemaObject documentSchema(APIDocumentContext context) {
|
||||
return (RuntimeContext.current[runtimeType] as SerializableRuntime)
|
||||
.documentSchema(context);
|
||||
}
|
||||
|
||||
/// Reads values from [object].
|
||||
///
|
||||
/// Use [read] instead of this method. [read] applies filters
|
||||
/// to [object] before calling this method.
|
||||
///
|
||||
/// This method is used by implementors to assign and use values from [object] for its own
|
||||
/// purposes. [SerializableException]s should be thrown when [object] violates a constraint
|
||||
/// of the receiver.
|
||||
void readFromMap(Map<String, dynamic> object);
|
||||
|
||||
/// Reads values from [object], after applying filters.
|
||||
///
|
||||
/// The key name must exactly match the name of the property as defined in the receiver's type.
|
||||
/// If [object] contains a key that is unknown to the receiver, an exception is thrown (status code: 400).
|
||||
///
|
||||
/// [accept], [ignore], [reject] and [require] are filters on [object]'s keys with the following behaviors:
|
||||
///
|
||||
/// If [accept] is set, all values for the keys that are not given are ignored and discarded.
|
||||
/// If [ignore] is set, all values for the given keys are ignored and discarded.
|
||||
/// If [reject] is set, if [object] contains any of these keys, a status code 400 exception is thrown.
|
||||
/// If [require] is set, all keys must be present in [object].
|
||||
///
|
||||
/// Usage:
|
||||
/// var values = json.decode(await request.body.decode());
|
||||
/// var user = User()
|
||||
/// ..read(values, ignore: ["id"]);
|
||||
void read(
|
||||
Map<String, dynamic> object, {
|
||||
Iterable<String>? accept,
|
||||
Iterable<String>? ignore,
|
||||
Iterable<String>? reject,
|
||||
Iterable<String>? require,
|
||||
}) {
|
||||
if (accept == null && ignore == null && reject == null && require == null) {
|
||||
readFromMap(object);
|
||||
return;
|
||||
}
|
||||
|
||||
final copy = Map<String, dynamic>.from(object);
|
||||
final stillRequired = require?.toList();
|
||||
for (final key in object.keys) {
|
||||
if (reject?.contains(key) ?? false) {
|
||||
throw SerializableException(["invalid input key '$key'"]);
|
||||
}
|
||||
if ((ignore?.contains(key) ?? false) ||
|
||||
!(accept?.contains(key) ?? true)) {
|
||||
copy.remove(key);
|
||||
}
|
||||
stillRequired?.remove(key);
|
||||
}
|
||||
|
||||
if (stillRequired?.isNotEmpty ?? false) {
|
||||
throw SerializableException(
|
||||
["missing required input key(s): '${stillRequired!.join(", ")}'"],
|
||||
);
|
||||
}
|
||||
|
||||
readFromMap(copy);
|
||||
}
|
||||
|
||||
/// Returns a serializable version of an object.
|
||||
///
|
||||
/// This method returns a [Map<String, dynamic>] where each key is the name of a property in the implementing type.
|
||||
/// If a [Response.body]'s type implements this interface, this method is invoked prior to any content-type encoding
|
||||
/// performed by the [Response]. A [Response.body] may also be a [List<Serializable>], for which this method is invoked on
|
||||
/// each element in the list.
|
||||
Map<String, dynamic> asMap();
|
||||
|
||||
/// Whether a subclass will automatically be registered as a schema component automatically.
|
||||
///
|
||||
/// Defaults to true. When an instance of this subclass is used in a [ResourceController],
|
||||
/// it will automatically be registered as a schema component. Its properties will be reflected
|
||||
/// on to create the [APISchemaObject]. If false, you must register a schema for the subclass manually.
|
||||
///
|
||||
/// Overriding static methods is not enforced by the Dart compiler - check for typos.
|
||||
static bool get shouldAutomaticallyDocument => true;
|
||||
}
|
||||
|
||||
class SerializableException implements HandlerException {
|
||||
SerializableException(this.reasons);
|
||||
|
||||
final List<String> reasons;
|
||||
|
||||
@override
|
||||
Response get response {
|
||||
return Response.badRequest(
|
||||
body: {"error": "entity validation failed", "reasons": reasons},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final errorString = response.body["error"] as String?;
|
||||
final reasons = (response.body["reasons"] as List).join(", ");
|
||||
return "$errorString $reasons";
|
||||
}
|
||||
}
|
||||
|
||||
abstract class SerializableRuntime {
|
||||
APISchemaObject documentSchema(APIDocumentContext context);
|
||||
}
|
|
@ -10,7 +10,14 @@ environment:
|
|||
|
||||
# Add regular dependencies here.
|
||||
dependencies:
|
||||
#path: ^1.9.0
|
||||
protevus_runtime: ^0.0.1
|
||||
protevus_openapi: ^0.0.1
|
||||
protevus_auth: ^0.0.1
|
||||
protevus_database: ^0.0.1
|
||||
collection: ^1.18.0
|
||||
logging: ^1.2.0
|
||||
meta: ^1.12.0
|
||||
path: ^1.9.0
|
||||
|
||||
dev_dependencies:
|
||||
lints: ^3.0.0
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue